From 3b243bacdafc2517bb170e267646e66ba329a054 Mon Sep 17 00:00:00 2001 From: I-Chenn Date: Wed, 19 Apr 2023 14:50:04 +0800 Subject: [PATCH 01/16] Add load_stokes_hypercube to session --- carta/session.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/carta/session.py b/carta/session.py index 0ba2e22..98fabda 100644 --- a/carta/session.py +++ b/carta/session.py @@ -391,6 +391,31 @@ def append_image(self, path, hdu="", complex=False, expression=ArithmeticExpress Arithmetic expression to use if appending a complex-valued image. The default is :obj:`carta.constants.ArithmeticExpression.AMPLITUDE`. """ return Image.new(self, path, hdu, True, complex, expression) + + def load_stokes_hypercube(self, output_direcory, directories, files, polarization_type, hdu="", append=False): + """Open or append a new Stokes hypercube with the selected Stokes images. + + Parameters + ---------- + output_direcory : {0} + The direcory to output the hypercube, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + directories : {1} + The list of paths to the images, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + files : {2} + The list of images for making the hypercube. + polarization_type : {3} + The list of the polarization types assigned to the respective images. + hdu : {4} + The HDU to select inside the file. + append : {5} + Whether the hypercube should be appended. Default is ``False``. + """ + stokes_files = [] + for i in range(len(directories)): + stoke_file = {"directory": directories[i], "file": files[i], "hdu": hdu, "polarizationType": polarization_type[i]} + stokes_files.append(stoke_file) + command = "appendConcatFile" if append else "openConcatFile" + self.call_action(command, stokes_files, output_direcory, hdu) def image_list(self): """Return the list of currently open images. From c4bc4f90488714e063e892dd9af06c2d388b92c4 Mon Sep 17 00:00:00 2001 From: I-Chenn Date: Fri, 21 Apr 2023 18:53:41 +0800 Subject: [PATCH 02/16] Add structs for StokesImage --- carta/constants.py | 25 +++++++++++++++++++++++++ carta/session.py | 30 ++++++++++++------------------ carta/structs.py | 16 ++++++++++++++++ 3 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 carta/structs.py diff --git a/carta/constants.py b/carta/constants.py index 57eb9d0..f854556 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -127,6 +127,31 @@ class ContourDashMode(str, Enum): NEGATIVE_ONLY = "NegativeOnly" +PROTO_POLARIZATION = { + "I": 1, + "Q": 2, + "U": 3, + "V": 4, + "RR": 5, + "LL": 6, + "RL": 7, + "LR": 8, + "XX": 9, + "YY": 10, + "XY": 11, + "YX": 12, + "Ptotal": 13, + "Plinear": 14, + "PFtotal": 15, + "PFlinear": 16, + "PANGLE": 17, +} + + +def __init__(self, value): + self.proto_index = PROTO_POLARIZATION[self.name] + + class Polarization(IntEnum): """Polarizations, corresponding to the POLARIZATIONS enum in the frontend.""" YX = -8 diff --git a/carta/session.py b/carta/session.py index 98fabda..f4b9f72 100644 --- a/carta/session.py +++ b/carta/session.py @@ -7,6 +7,7 @@ """ import base64 +import posixpath from .image import Image from .constants import CoordinateSystem, LabelType, BeamType, PaletteColor, Overlay, PanelMode, GridMode, ArithmeticExpression @@ -391,31 +392,24 @@ def append_image(self, path, hdu="", complex=False, expression=ArithmeticExpress Arithmetic expression to use if appending a complex-valued image. The default is :obj:`carta.constants.ArithmeticExpression.AMPLITUDE`. """ return Image.new(self, path, hdu, True, complex, expression) - - def load_stokes_hypercube(self, output_direcory, directories, files, polarization_type, hdu="", append=False): + + # @validate(IterableOf(InstanceOf(StokesImage), min_size=2), Boolean()) + def load_stokes_hypercube(self, stokes_images, append=False): """Open or append a new Stokes hypercube with the selected Stokes images. Parameters ---------- - output_direcory : {0} - The direcory to output the hypercube, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. - directories : {1} - The list of paths to the images, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. - files : {2} - The list of images for making the hypercube. - polarization_type : {3} - The list of the polarization types assigned to the respective images. - hdu : {4} - The HDU to select inside the file. - append : {5} + stokes_images : {0} + The list of the paths to the images, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + append : {1} Whether the hypercube should be appended. Default is ``False``. """ - stokes_files = [] - for i in range(len(directories)): - stoke_file = {"directory": directories[i], "file": files[i], "hdu": hdu, "polarizationType": polarization_type[i]} - stokes_files.append(stoke_file) + for image in stokes_images: + image.directory = self.resolve_file_path(image.directory) + output_directory = Macro("fileBrowserStore", "startingDirectory") + output_hdu = "" command = "appendConcatFile" if append else "openConcatFile" - self.call_action(command, stokes_files, output_direcory, hdu) + self.call_action(command, stokes_images, output_directory, output_hdu) def image_list(self): """Return the list of currently open images. diff --git a/carta/structs.py b/carta/structs.py new file mode 100644 index 0000000..f7e2120 --- /dev/null +++ b/carta/structs.py @@ -0,0 +1,16 @@ +import posixpath + +from .validation import validate, String, Constant +from .constants import Polarization, PROTO_POLARIZATION + + +class StokesImage: + + @validate(Constant(PROTO_POLARIZATION), String(), String()) + def __init__(self, stokes, path, hdu=""): + self.stokes = stokes + self.directory, self.file_name = posixpath.split(path) + self.hdu = hdu + + def json(self): + return {"directory": self.directory, "file": self.file_name, "hdu": self.hdu, "polarizationType": self.stokes.proto_index} From 382f5b758f2626d2f149c869564ab55512bf637b Mon Sep 17 00:00:00 2001 From: I-Chenn Date: Sat, 22 Apr 2023 16:03:08 +0800 Subject: [PATCH 03/16] Minor fix and add description --- carta/constants.py | 16 ++++++++-------- carta/session.py | 7 ++++--- carta/structs.py | 26 ++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/carta/constants.py b/carta/constants.py index f854556..d9f7b42 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -140,20 +140,20 @@ class ContourDashMode(str, Enum): "YY": 10, "XY": 11, "YX": 12, - "Ptotal": 13, - "Plinear": 14, - "PFtotal": 15, - "PFlinear": 16, + "PTOTAL": 13, + "PLINEAR": 14, + "PFTOTAL": 15, + "PFLINEAR": 16, "PANGLE": 17, } -def __init__(self, value): - self.proto_index = PROTO_POLARIZATION[self.name] - - class Polarization(IntEnum): """Polarizations, corresponding to the POLARIZATIONS enum in the frontend.""" + + def __init__(self, value): + self.proto_index = PROTO_POLARIZATION[self.name] + YX = -8 XY = -7 YY = -6 diff --git a/carta/session.py b/carta/session.py index f4b9f72..6f2bacb 100644 --- a/carta/session.py +++ b/carta/session.py @@ -14,7 +14,8 @@ from .backend import Backend from .protocol import Protocol from .util import logger, Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl -from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, OneOf +from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, OneOf, IterableOf, InstanceOf +from .structs import StokesImage class Session: @@ -393,14 +394,14 @@ def append_image(self, path, hdu="", complex=False, expression=ArithmeticExpress """ return Image.new(self, path, hdu, True, complex, expression) - # @validate(IterableOf(InstanceOf(StokesImage), min_size=2), Boolean()) + @validate(IterableOf(InstanceOf(StokesImage), min_size=2), Boolean()) def load_stokes_hypercube(self, stokes_images, append=False): """Open or append a new Stokes hypercube with the selected Stokes images. Parameters ---------- stokes_images : {0} - The list of the paths to the images, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + The list of images, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. append : {1} Whether the hypercube should be appended. Default is ``False``. """ diff --git a/carta/structs.py b/carta/structs.py index f7e2120..4aaaedf 100644 --- a/carta/structs.py +++ b/carta/structs.py @@ -1,12 +1,34 @@ +"""This module provides a collection of helper objects for grouping related values together.""" + import posixpath from .validation import validate, String, Constant -from .constants import Polarization, PROTO_POLARIZATION +from .constants import Polarization class StokesImage: + '''An object which groups information about an image file to be used as a component in a Stokes hypercube. + + Parameters + ---------- + stokes : + The Stokes type to be specied. + path : str + The path to the image file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + hdu : str + The HDU to open. + + Attributes + ---------- + stokes : + The Stokes type to be specified. + path : str + The path to the image file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + hdu : str + The HDU to open. + ''' - @validate(Constant(PROTO_POLARIZATION), String(), String()) + @validate(Constant(Polarization), String(), String()) def __init__(self, stokes, path, hdu=""): self.stokes = stokes self.directory, self.file_name = posixpath.split(path) From dfd2c2721c58b0d890b59474d8ab7d89f1eefdac Mon Sep 17 00:00:00 2001 From: I-Chenn Date: Mon, 24 Apr 2023 14:53:53 +0800 Subject: [PATCH 04/16] Minor modification to description --- carta/structs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/carta/structs.py b/carta/structs.py index 4aaaedf..efa32a2 100644 --- a/carta/structs.py +++ b/carta/structs.py @@ -12,18 +12,18 @@ class StokesImage: Parameters ---------- stokes : - The Stokes type to be specied. + The Stokes type to specify. path : str - The path to the image file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + The path to the image file. hdu : str The HDU to open. Attributes ---------- stokes : - The Stokes type to be specified. + The Stokes type to specify. path : str - The path to the image file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + The path to the image file. hdu : str The HDU to open. ''' From aa04e41316e6d1bc478514368a102acfff6d8212 Mon Sep 17 00:00:00 2001 From: I-Chenn Date: Tue, 25 Apr 2023 13:47:14 +0800 Subject: [PATCH 05/16] Add structs to rst file and minor modification to description --- carta/structs.py | 3 ++- docs/source/carta.rst | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/carta/structs.py b/carta/structs.py index efa32a2..46fcdba 100644 --- a/carta/structs.py +++ b/carta/structs.py @@ -21,7 +21,7 @@ class StokesImage: Attributes ---------- stokes : - The Stokes type to specify. + The Stokes type to specify. Must be a member of :obj:`carta.constants.Polarization` path : str The path to the image file. hdu : str @@ -35,4 +35,5 @@ def __init__(self, stokes, path, hdu=""): self.hdu = hdu def json(self): + """The JSON serialization of this object.""" return {"directory": self.directory, "file": self.file_name, "hdu": self.hdu, "polarizationType": self.stokes.proto_index} diff --git a/docs/source/carta.rst b/docs/source/carta.rst index 2b6cee9..e75794d 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -49,6 +49,14 @@ carta.session module :undoc-members: :show-inheritance: +carta.structs module +-------------------- + +.. automodule:: carta.structs + :members: + :undoc-members: + :show-inheritance: + carta.token module ------------------ From 13ed206f19b1335a634729c0974c0589c99de4ff Mon Sep 17 00:00:00 2001 From: I-Chenn Date: Tue, 25 Apr 2023 13:55:57 +0800 Subject: [PATCH 06/16] Minor modification to description --- carta/structs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/carta/structs.py b/carta/structs.py index 46fcdba..739b621 100644 --- a/carta/structs.py +++ b/carta/structs.py @@ -11,8 +11,8 @@ class StokesImage: Parameters ---------- - stokes : - The Stokes type to specify. + stokes : :obj:`carta.constants.Polarization` + The Stokes type to specify. Must be a member of :obj:`carta.constants.Polarization` path : str The path to the image file. hdu : str @@ -20,7 +20,7 @@ class StokesImage: Attributes ---------- - stokes : + stokes : :obj:`carta.constants.Polarization` The Stokes type to specify. Must be a member of :obj:`carta.constants.Polarization` path : str The path to the image file. From 27031ca515f33cf91ea837e0a45a3b6f88610859 Mon Sep 17 00:00:00 2001 From: I-Chenn Date: Tue, 25 Apr 2023 21:24:34 +0800 Subject: [PATCH 07/16] Minor description modified --- carta/structs.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/carta/structs.py b/carta/structs.py index 739b621..4bf4600 100644 --- a/carta/structs.py +++ b/carta/structs.py @@ -11,17 +11,17 @@ class StokesImage: Parameters ---------- - stokes : :obj:`carta.constants.Polarization` - The Stokes type to specify. Must be a member of :obj:`carta.constants.Polarization` - path : str + stokes : a member of :obj:`carta.constants.Polarization` + The Stokes type to specify. + path : string The path to the image file. - hdu : str + hdu : string The HDU to open. Attributes ---------- - stokes : :obj:`carta.constants.Polarization` - The Stokes type to specify. Must be a member of :obj:`carta.constants.Polarization` + stokes : a member of :obj:`carta.constants.Polarization` + The Stokes type to specify. path : str The path to the image file. hdu : str From e29d6b84242177cedde3ec3727149b48c2efda83 Mon Sep 17 00:00:00 2001 From: I-Chenn Date: Tue, 2 May 2023 17:19:46 +0800 Subject: [PATCH 08/16] Adopt pwd for output_directory --- carta/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carta/session.py b/carta/session.py index 6f2bacb..c98d8f5 100644 --- a/carta/session.py +++ b/carta/session.py @@ -407,7 +407,7 @@ def load_stokes_hypercube(self, stokes_images, append=False): """ for image in stokes_images: image.directory = self.resolve_file_path(image.directory) - output_directory = Macro("fileBrowserStore", "startingDirectory") + output_directory = self.pwd() output_hdu = "" command = "appendConcatFile" if append else "openConcatFile" self.call_action(command, stokes_images, output_directory, output_hdu) From 4ad82504a6d180c2ef0551cebcf7c90c0cf952e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 31 Jul 2023 20:55:11 +0200 Subject: [PATCH 09/16] First pass to change API. Untested. --- carta/image.py | 116 +++++++++++---------------------------- carta/metadata.py | 130 ++++++++++++++++++++++++++++++++++++++++++++ carta/session.py | 60 ++++++++++++++++---- carta/structs.py | 39 ------------- carta/validation.py | 57 +++++++++++++++++++ 5 files changed, 270 insertions(+), 132 deletions(-) create mode 100644 carta/metadata.py delete mode 100644 carta/structs.py diff --git a/carta/image.py b/carta/image.py index 41a40fd..8ab484e 100644 --- a/carta/image.py +++ b/carta/image.py @@ -2,6 +2,7 @@ Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ +from .metadata import ImageInfo from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, CoordinateSystem, SpatialAxis from .util import Macro, cached, PixelValue, AngularSize, WorldCoordinate from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate @@ -31,11 +32,12 @@ class Image: The file name of the image. """ - def __init__(self, session, image_id, file_name): + def __init__(self, session, image_id, file_name, info=None, info_extended=None): self.session = session self.image_id = image_id self.file_name = file_name - + + self._image_info = ImageInfo(info, info_extended) if info and info_extended else None self._base_path = f"frameMap[{image_id}]" self._frame = Macro("", self._base_path) @@ -77,8 +79,8 @@ 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, file_name) + frame_info = session.call_action(command, *params, return_path="frameInfo") + return cls(session, frame_info["fileId"], file_name, frame_info["fileInfo"], frame_info["fileInfoExtended"]) @classmethod def from_list(cls, session, image_list): @@ -102,7 +104,7 @@ def from_list(cls, session, image_list): def __repr__(self): return f"{self.session.session_id}:{self.image_id}:{self.file_name}" - + def call_action(self, path, *args, **kwargs): """Convenience wrapper for the session object's generic action method. @@ -162,6 +164,13 @@ def macro(self, target, variable): return Macro(target, variable) # METADATA + + @property + def image_info(self): + if self._image_info is None: + frame_info = self.get_value("frameInfo") + self._image_info = ImageInfo(frame_info["fileInfo"], frame_info["fileInfoExtended"]) + return self._image_info @property @cached @@ -176,76 +185,17 @@ def directory(self): return self.get_value("frameInfo.directory") @property - @cached def header(self): """The header of the image. - Entries with T or F string values are automatically converted to booleans. - - ``HISTORY``, ``COMMENT`` and blank keyword entries are aggregated into single entries with list values and with ``'HISTORY'``, ``'COMMENT'`` and ``''`` as keys, respectively. An entry in the history list which begins with ``'>'`` will be concatenated with the previous entry. - - Adjacent ``COMMENT`` entries are not concatenated automatically. - - Any other header entries with no values are given values of ``None``. + See :obj:`carta.metadata.ImageInfo.header` for more information about how the raw header data is processed. Returns ------- dict of string to string, integer, float, boolean, ``None`` or list of strings The header of the image, with field names as keys. """ - raw_header = self.get_value("frameInfo.fileInfoExtended.headerEntries") - - header = {} - - history = [] - comment = [] - blank = [] - - def header_value(raw_entry): - try: - return raw_entry["numericValue"] - except KeyError: - try: - value = raw_entry["value"] - if value == 'T': - return True - if value == 'F': - return False - return value - except KeyError: - return None - - for i, raw_entry in enumerate(raw_header): - name = raw_entry["name"] - - if name.startswith("HISTORY "): - line = name[8:] - if line.startswith(">") and history: - history[-1] = history[-1] + line[1:] - else: - history.append(line) - continue - - if name.startswith("COMMENT "): - comment.append(name[8:]) - continue - - if name.startswith(" " * 8): - blank.append(name[8:]) - continue - - header[name] = header_value(raw_entry) - - if history: - header["HISTORY"] = history - - if comment: - header["COMMENT"] = comment - - if blank: - header[""] = blank - - return header + return self.image_info.header @property @cached @@ -270,7 +220,7 @@ def width(self): integer The width. """ - return self.get_value("frameInfo.fileInfoExtended.width") + return self.metadata.info_extended["width"] @property @cached @@ -282,7 +232,7 @@ def height(self): integer The height. """ - return self.get_value("frameInfo.fileInfoExtended.height") + return self.metadata.info_extended["height"] @property @cached @@ -294,7 +244,7 @@ def depth(self): integer The depth. """ - return self.get_value("frameInfo.fileInfoExtended.depth") + return self.metadata.info_extended["depth"] @property @cached @@ -306,7 +256,7 @@ def num_polarizations(self): integer The number of polarizations. """ - return self.get_value("frameInfo.fileInfoExtended.stokes") + return self.metadata.info_extended["stokes"] @property @cached @@ -318,7 +268,7 @@ def ndim(self): integer The number of dimensions. """ - return self.get_value("frameInfo.fileInfoExtended.dimensions") + return self.metadata.info_extended["dimensions"] @property @cached @@ -334,6 +284,18 @@ def polarizations(self): """ return [Polarization(p) for p in self.get_value("polarizations")] + @property + @cached + def valid_wcs(self): + """Whether the image contains valid WCS information. + + Returns + ------- + boolean + Whether the image has WCS information. + """ + return self.get_value("validWcs") + # SELECTION def make_active(self): @@ -428,18 +390,6 @@ def set_polarization(self, polarization, recursive=True): self.call_action("setChannels", self.macro("", "requiredChannel"), polarization, recursive) - @property - @cached - def valid_wcs(self): - """Whether the image contains valid WCS information. - - Returns - ------- - boolean - Whether the image has WCS information. - """ - return self.get_value("validWcs") - @validate(Coordinate(), Coordinate(), NoneOr(Constant(CoordinateSystem))) def set_center(self, x, y, system=None): """Set the center position, in image or world coordinates. Optionally change the session-wide coordinate system. diff --git a/carta/metadata.py b/carta/metadata.py new file mode 100644 index 0000000..194e869 --- /dev/null +++ b/carta/metadata.py @@ -0,0 +1,130 @@ +"""This module provides a collection of helper objects for storing and accessing file metadata.""" + +import re + +from .util import cached +from .constants import Polarization + +class ImageInfo: + """This class stores metadata for an image file. + + Parameters + ---------- + info : dict + The basic metadata for this image, as received from the frontend. + info_extended : dict + The extended metadata for this image, as received from the frontend. + + Attributes + ---------- + info : dict + The basic metadata for this image, as received from the frontend. + info_extended : dict + The extended metadata for this image, as received from the frontend. + """ + + def __init__(self, info, info_extended): + self.info = info + self.info_extended = info_extended + + @property + @cached + def header(self): + """The header of the image. + + Entries with T or F string values are automatically converted to booleans. + + ``HISTORY``, ``COMMENT`` and blank keyword entries are aggregated into single entries with list values and with ``'HISTORY'``, ``'COMMENT'`` and ``''`` as keys, respectively. An entry in the history list which begins with ``'>'`` will be concatenated with the previous entry. + + Adjacent ``COMMENT`` entries are not concatenated automatically. + + Any other header entries with no values are given values of ``None``. + + Returns + ------- + dict of string to string, integer, float, boolean, ``None`` or list of strings + The header of the image, with field names as keys. + """ + raw_header = self.info_extended["headerEntries"] + + header = {} + + history = [] + comment = [] + blank = [] + + def header_value(raw_entry): + try: + return raw_entry["numericValue"] + except KeyError: + try: + value = raw_entry["value"] + if value == 'T': + return True + if value == 'F': + return False + return value + except KeyError: + return None + + for i, raw_entry in enumerate(raw_header): + name = raw_entry["name"] + + if name.startswith("HISTORY "): + line = name[8:] + if line.startswith(">") and history: + history[-1] = history[-1] + line[1:] + else: + history.append(line) + continue + + if name.startswith("COMMENT "): + comment.append(name[8:]) + continue + + if name.startswith(" " * 8): + blank.append(name[8:]) + continue + + header[name] = header_value(raw_entry) + + if history: + header["HISTORY"] = history + + if comment: + header["COMMENT"] = comment + + if blank: + header[""] = blank + + return header + + def deduce_polarization(self): + """Deduce the polarization of the image from its metadata.""" + polarization = None + + ctype_header = [k for k, v in self.header.items() if k.startswith("CTYPE") and v.upper() == "STOKES"] + if ctype_header: + index = ctype_header[0][5:] + naxis = self.header.get(f"NAXIS{index}", None) + crpix = self.header.get(f"CRPIX{index}", None) + crval = self.header.get(f"CRVAL{index}", None) + cdelt = self.header.get(f"CDELT{index}", None) + + if all(naxis, crpix, crval, cdelt) and naxis == 1: + polarization_index = crval + (1 - crpix) * cdelt + try: + return Polarization(polarization_index) + except ValueError: + pass + + if polarization is None: + name_parts = re.split([._], self.info["name"]) + matches = [] + for part in name_parts: + if hasattr(Polarization, part.upper()): + matches.append(getattr(Polarization, part.upper())) + if len(matches) == 1: + return matches[0] + + raise ValueError(f"Could not deduce polarization from image file {self.info["name"]}.") diff --git a/carta/session.py b/carta/session.py index 8e078cf..398eaa4 100644 --- a/carta/session.py +++ b/carta/session.py @@ -15,7 +15,7 @@ from .protocol import Protocol from .util import logger, Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, OneOf, IterableOf, InstanceOf -from .structs import StokesImage +from .metadata import ImageInfo class Session: @@ -400,20 +400,39 @@ def open_LEL_image(self, expression, directory=".", append=False, make_active=Tr Whether the starting directory of the frontend file browser should be updated to the base directory of the LEL expression. The default is ``False``. """ return Image.new(self, directory, expression, "", append, True, make_active=make_active, update_directory=update_directory) - - @validate(IterableOf(InstanceOf(StokesImage), min_size=2), Boolean()) - def load_stokes_hypercube(self, stokes_images, append=False): - """Open or append a new Stokes hypercube with the selected Stokes images. + + @validate(Union(IterableOf(String), MapOf(Constant(Polarization), String())), Boolean()) + def open_hypercube(self, image_paths, append=False): + """Open multiple images merged into a polarization hypercube. Parameters ---------- - stokes_images : {0} - The list of images, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + image_paths : {0} + The image paths, either relative to the session's current directory or absolute paths relative to the CARTA backend's root directory. If this is a list of paths, the polarizations will be deduced from the image headers or names. If this is a dictionary, the polarizations must be used as keys. append : {1} Whether the hypercube should be appended. Default is ``False``. - """ - for image in stokes_images: - image.directory = self.resolve_file_path(image.directory) + + Raises + ------ + ValueError + If explicit polarizations are not provided, and cannot be deduced from the image headers or names. + """ + stokes_images = [] + + if isinstance(image_paths, dict): + for stokes, path in image_paths.items(): + directory, file_name = posixpath.split(path) + stokes_images.append({"directory": directory, "file": file_name, "hdu": "", "polarizationType": stokes.proto_index}) + else: + for path in image_paths: + directory, file_name = posixpath.split(path) + image_info = self.image_info(path) + try: + stokes = image_info.deduce_polarization() + except ValueError: + raise ValueError(f"Could not deduce polarization for {path}. Please use a dictionary to specify the polarization mapping explicitly.") + stokes_images.append({"directory": directory, "file": file_name, "hdu": "", "polarizationType": stokes.proto_index}) + output_directory = self.pwd() output_hdu = "" command = "appendConcatFile" if append else "openConcatFile" @@ -425,8 +444,29 @@ def image_list(self): Returns ------- list of :obj:`carta.image.Image` objects. + The list of images open in this session. """ return Image.from_list(self, self.get_value("frameNames")) + + @validate(String()) + def image_info(self, path, hdu=""): + """Returns metadata for the specified image file. + + This is mostly provided as an internal helper function. + + Parameters + ---------- + path : {0} + The path to the image file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + + Returns + ------- + :obj:`carta.metadata.ImageInfo` object + The basic and extended metadata for this image. + """ + directory, file_name = posixpath.split(path) + file_info = self.call_action("backendService.getFileInfo", directory, file_name, hdu) + return ImageInfo(file_info["fileInfo"], file_info["fileInfoExtended"]) def active_frame(self): """Return the currently active image. diff --git a/carta/structs.py b/carta/structs.py deleted file mode 100644 index 4bf4600..0000000 --- a/carta/structs.py +++ /dev/null @@ -1,39 +0,0 @@ -"""This module provides a collection of helper objects for grouping related values together.""" - -import posixpath - -from .validation import validate, String, Constant -from .constants import Polarization - - -class StokesImage: - '''An object which groups information about an image file to be used as a component in a Stokes hypercube. - - Parameters - ---------- - stokes : a member of :obj:`carta.constants.Polarization` - The Stokes type to specify. - path : string - The path to the image file. - hdu : string - The HDU to open. - - Attributes - ---------- - stokes : a member of :obj:`carta.constants.Polarization` - The Stokes type to specify. - path : str - The path to the image file. - hdu : str - The HDU to open. - ''' - - @validate(Constant(Polarization), String(), String()) - def __init__(self, stokes, path, hdu=""): - self.stokes = stokes - self.directory, self.file_name = posixpath.split(path) - self.hdu = hdu - - def json(self): - """The JSON serialization of this object.""" - return {"directory": self.directory, "file": self.file_name, "hdu": self.hdu, "polarizationType": self.stokes.proto_index} diff --git a/carta/validation.py b/carta/validation.py index c6464d9..95ff0d4 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -536,6 +536,63 @@ def description(self): if size: size_desc = f"with {' and '.join(size)} " return f"an iterable {size_desc}in which each element is {self.param.description}" + + +def MapOf(IterableOf): + """A dictionary of keys and values which must match the given descriptors. + + Parameters + ---------- + key_param : :obj:`carta.validation.Parameter` + The key parameter descriptor. + value_param : :obj:`carta.validation.Parameter` + The value parameter descriptor. + min_size : integer, optional + The minimum size. + max_size : integer, optional + The maximum size. + + Attributes + ---------- + param : :obj:`carta.validation.Parameter` + The parameter descriptor. + min_size : integer, optional + The minimum size. + max_size : integer, optional + The maximum size. + """ + + def __init__(self, key_param, value_param, min_size=None, max_size=None): + self.value_param = value_param + super().__init__(key_param, min_size, max_size) + + def validate(self, value, parent): + """Check if each element of the iterable can be validated with the given descriptor. + + See :obj:`carta.validation.Parameter.validate` for general information about this method. + """ + + try: + for v in value.values(): + self.value_param.validate(v, parent) + except TypeError as e: + if str(e).endswith("object is not iterable"): + raise ValueError(f"{value} is not iterable, but {self.description} was expected.") + raise e + + super().validate(value, parent) + + @property + def description(self): + """A human-readable description of this parameter descriptor. + + Returns + ------- + string + The description. + """ + + return re.sub("^an iterable (.*?)in which each element is (.*)$", rf"a dictionary \1in which each key is {self.param.description} and each value is {self.value_param.description}", super().description) COLORNAMES = ('aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgrey', 'darkgreen', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'grey', 'green', 'greenyellow', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgrey', 'lightgreen', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen') From eff2e0965a3d538f879a54dd9ad8adbf0c5a01fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 31 Jul 2023 21:50:19 +0200 Subject: [PATCH 10/16] reverted some unnecessary complications --- carta/image.py | 62 ++++++------- carta/metadata.py | 215 ++++++++++++++++++++++---------------------- carta/session.py | 31 ++----- carta/validation.py | 16 +--- 4 files changed, 142 insertions(+), 182 deletions(-) diff --git a/carta/image.py b/carta/image.py index 8ab484e..866115d 100644 --- a/carta/image.py +++ b/carta/image.py @@ -2,10 +2,10 @@ Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ -from .metadata import ImageInfo from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, CoordinateSystem, SpatialAxis from .util import Macro, cached, PixelValue, AngularSize, WorldCoordinate from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate +from .metadata import parse_header class Image: @@ -32,12 +32,11 @@ class Image: The file name of the image. """ - def __init__(self, session, image_id, file_name, info=None, info_extended=None): + def __init__(self, session, image_id, file_name): self.session = session self.image_id = image_id self.file_name = file_name - - self._image_info = ImageInfo(info, info_extended) if info and info_extended else None + self._base_path = f"frameMap[{image_id}]" self._frame = Macro("", self._base_path) @@ -79,8 +78,8 @@ def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_ params.append(make_active) params.append(update_directory) - frame_info = session.call_action(command, *params, return_path="frameInfo") - return cls(session, frame_info["fileId"], file_name, frame_info["fileInfo"], frame_info["fileInfoExtended"]) + image_id = session.call_action(command, *params, return_path="frameInfo.fileId") + return cls(session, image_id, file_name) @classmethod def from_list(cls, session, image_list): @@ -104,7 +103,7 @@ def from_list(cls, session, image_list): def __repr__(self): return f"{self.session.session_id}:{self.image_id}:{self.file_name}" - + def call_action(self, path, *args, **kwargs): """Convenience wrapper for the session object's generic action method. @@ -164,13 +163,6 @@ def macro(self, target, variable): return Macro(target, variable) # METADATA - - @property - def image_info(self): - if self._image_info is None: - frame_info = self.get_value("frameInfo") - self._image_info = ImageInfo(frame_info["fileInfo"], frame_info["fileInfoExtended"]) - return self._image_info @property @cached @@ -185,17 +177,17 @@ def directory(self): return self.get_value("frameInfo.directory") @property + @cached def header(self): - """The header of the image. - - See :obj:`carta.metadata.ImageInfo.header` for more information about how the raw header data is processed. + """The header of the image, parsed from the raw frontend data (see :obj:`carta.metadata.parse_header`). Returns ------- dict of string to string, integer, float, boolean, ``None`` or list of strings The header of the image, with field names as keys. """ - return self.image_info.header + raw_header = self.get_value("frameInfo.fileInfoExtended.headerEntries") + return parse_header(raw_header) @property @cached @@ -220,7 +212,7 @@ def width(self): integer The width. """ - return self.metadata.info_extended["width"] + return self.get_value("frameInfo.fileInfoExtended.width") @property @cached @@ -232,7 +224,7 @@ def height(self): integer The height. """ - return self.metadata.info_extended["height"] + return self.get_value("frameInfo.fileInfoExtended.height") @property @cached @@ -244,7 +236,7 @@ def depth(self): integer The depth. """ - return self.metadata.info_extended["depth"] + return self.get_value("frameInfo.fileInfoExtended.depth") @property @cached @@ -256,7 +248,7 @@ def num_polarizations(self): integer The number of polarizations. """ - return self.metadata.info_extended["stokes"] + return self.get_value("frameInfo.fileInfoExtended.stokes") @property @cached @@ -268,7 +260,7 @@ def ndim(self): integer The number of dimensions. """ - return self.metadata.info_extended["dimensions"] + return self.get_value("frameInfo.fileInfoExtended.dimensions") @property @cached @@ -284,18 +276,6 @@ def polarizations(self): """ return [Polarization(p) for p in self.get_value("polarizations")] - @property - @cached - def valid_wcs(self): - """Whether the image contains valid WCS information. - - Returns - ------- - boolean - Whether the image has WCS information. - """ - return self.get_value("validWcs") - # SELECTION def make_active(self): @@ -390,6 +370,18 @@ def set_polarization(self, polarization, recursive=True): self.call_action("setChannels", self.macro("", "requiredChannel"), polarization, recursive) + @property + @cached + def valid_wcs(self): + """Whether the image contains valid WCS information. + + Returns + ------- + boolean + Whether the image has WCS information. + """ + return self.get_value("validWcs") + @validate(Coordinate(), Coordinate(), NoneOr(Constant(CoordinateSystem))) def set_center(self, x, y, system=None): """Set the center position, in image or world coordinates. Optionally change the session-wide coordinate system. diff --git a/carta/metadata.py b/carta/metadata.py index 194e869..7c99f54 100644 --- a/carta/metadata.py +++ b/carta/metadata.py @@ -2,129 +2,126 @@ import re -from .util import cached from .constants import Polarization -class ImageInfo: - """This class stores metadata for an image file. +def parse_header(self, raw_header): + """Parse raw image header entries from the frontend into a more user-friendly format. + + Entries with T or F string values are automatically converted to booleans. + + ``HISTORY``, ``COMMENT`` and blank keyword entries are aggregated into single entries with list values and with ``'HISTORY'``, ``'COMMENT'`` and ``''`` as keys, respectively. An entry in the history list which begins with ``'>'`` will be concatenated with the previous entry. + + Adjacent ``COMMENT`` entries are not concatenated automatically. + + Any other header entries with no values are given values of ``None``. Parameters ---------- - info : dict - The basic metadata for this image, as received from the frontend. - info_extended : dict - The extended metadata for this image, as received from the frontend. - - Attributes - ---------- - info : dict - The basic metadata for this image, as received from the frontend. - info_extended : dict - The extended metadata for this image, as received from the frontend. + raw_header : dict + The raw header entries received from the frontend. + + Returns + ------- + dict of string to string, integer, float, boolean, ``None`` or list of strings + The header of the image, with field names as keys. """ - - def __init__(self, info, info_extended): - self.info = info - self.info_extended = info_extended - - @property - @cached - def header(self): - """The header of the image. + header = {} - Entries with T or F string values are automatically converted to booleans. + history = [] + comment = [] + blank = [] - ``HISTORY``, ``COMMENT`` and blank keyword entries are aggregated into single entries with list values and with ``'HISTORY'``, ``'COMMENT'`` and ``''`` as keys, respectively. An entry in the history list which begins with ``'>'`` will be concatenated with the previous entry. + def header_value(raw_entry): + try: + return raw_entry["numericValue"] + except KeyError: + try: + value = raw_entry["value"] + if value == 'T': + return True + if value == 'F': + return False + return value + except KeyError: + return None - Adjacent ``COMMENT`` entries are not concatenated automatically. + for i, raw_entry in enumerate(raw_header): + name = raw_entry["name"] - Any other header entries with no values are given values of ``None``. + if name.startswith("HISTORY "): + line = name[8:] + if line.startswith(">") and history: + history[-1] = history[-1] + line[1:] + else: + history.append(line) + continue - Returns - ------- - dict of string to string, integer, float, boolean, ``None`` or list of strings - The header of the image, with field names as keys. - """ - raw_header = self.info_extended["headerEntries"] + if name.startswith("COMMENT "): + comment.append(name[8:]) + continue - header = {} + if name.startswith(" " * 8): + blank.append(name[8:]) + continue - history = [] - comment = [] - blank = [] + header[name] = header_value(raw_entry) - def header_value(raw_entry): - try: - return raw_entry["numericValue"] - except KeyError: - try: - value = raw_entry["value"] - if value == 'T': - return True - if value == 'F': - return False - return value - except KeyError: - return None - - for i, raw_entry in enumerate(raw_header): - name = raw_entry["name"] - - if name.startswith("HISTORY "): - line = name[8:] - if line.startswith(">") and history: - history[-1] = history[-1] + line[1:] - else: - history.append(line) - continue - - if name.startswith("COMMENT "): - comment.append(name[8:]) - continue - - if name.startswith(" " * 8): - blank.append(name[8:]) - continue - - header[name] = header_value(raw_entry) - - if history: - header["HISTORY"] = history - - if comment: - header["COMMENT"] = comment - - if blank: - header[""] = blank - - return header + if history: + header["HISTORY"] = history + + if comment: + header["COMMENT"] = comment + + if blank: + header[""] = blank + + return header + + +def deduce_polarization(self, file_name, header): + """Deduce the polarization of an image from its metadata. - def deduce_polarization(self): - """Deduce the polarization of the image from its metadata.""" - polarization = None - - ctype_header = [k for k, v in self.header.items() if k.startswith("CTYPE") and v.upper() == "STOKES"] - if ctype_header: - index = ctype_header[0][5:] - naxis = self.header.get(f"NAXIS{index}", None) - crpix = self.header.get(f"CRPIX{index}", None) - crval = self.header.get(f"CRVAL{index}", None) - cdelt = self.header.get(f"CDELT{index}", None) - - if all(naxis, crpix, crval, cdelt) and naxis == 1: - polarization_index = crval + (1 - crpix) * cdelt - try: - return Polarization(polarization_index) - except ValueError: - pass + Parameters + ---------- + file_name : string + The name of the image file. + header : dict + The parsed header of the image file (see :obj:`carta.metadata.parse_header`). + + Returns + ------- + :obj:`carta.constants.Polarization` + The deduced polarization. - if polarization is None: - name_parts = re.split([._], self.info["name"]) - matches = [] - for part in name_parts: - if hasattr(Polarization, part.upper()): - matches.append(getattr(Polarization, part.upper())) - if len(matches) == 1: - return matches[0] + Raises + ------ + ValueError + If the polarization could not be deduced. + """ + polarization = None + + ctype_header = [k for k, v in header.items() if k.startswith("CTYPE") and v.upper() == "STOKES"] + if ctype_header: + index = ctype_header[0][5:] + naxis = header.get(f"NAXIS{index}", None) + crpix = header.get(f"CRPIX{index}", None) + crval = header.get(f"CRVAL{index}", None) + cdelt = header.get(f"CDELT{index}", None) - raise ValueError(f"Could not deduce polarization from image file {self.info["name"]}.") + if all(naxis, crpix, crval, cdelt) and naxis == 1: + polarization_index = crval + (1 - crpix) * cdelt + try: + return Polarization(polarization_index) + except ValueError: + pass + + if polarization is None: + name_parts = re.split("[._]", file_name) + matches = [] + for part in name_parts: + if hasattr(Polarization, part.upper()): + matches.append(getattr(Polarization, part.upper())) + if len(matches) == 1: + return matches[0] + + raise ValueError(f"Could not deduce polarization from image file {file_name}.") diff --git a/carta/session.py b/carta/session.py index 398eaa4..83745ae 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,12 +10,12 @@ import posixpath from .image import Image -from .constants import CoordinateSystem, LabelType, BeamType, PaletteColor, Overlay, PanelMode, GridMode, ComplexComponent, NumberFormat +from .constants import CoordinateSystem, LabelType, BeamType, PaletteColor, Overlay, PanelMode, GridMode, ComplexComponent, NumberFormat, Polarization from .backend import Backend from .protocol import Protocol from .util import logger, Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl -from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, OneOf, IterableOf, InstanceOf -from .metadata import ImageInfo +from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, OneOf, IterableOf, MapOf, Union +from .metadata import parse_header, deduce_polarization class Session: @@ -426,9 +426,10 @@ def open_hypercube(self, image_paths, append=False): else: for path in image_paths: directory, file_name = posixpath.split(path) - image_info = self.image_info(path) + image_info = self.call_action("backendService.getFileInfo", directory, file_name, "") + header = parse_header(image_info["fileInfoExtended"]["0"]["headerEntries"]) try: - stokes = image_info.deduce_polarization() + stokes = deduce_polarization(file_name, header) except ValueError: raise ValueError(f"Could not deduce polarization for {path}. Please use a dictionary to specify the polarization mapping explicitly.") stokes_images.append({"directory": directory, "file": file_name, "hdu": "", "polarizationType": stokes.proto_index}) @@ -447,26 +448,6 @@ def image_list(self): The list of images open in this session. """ return Image.from_list(self, self.get_value("frameNames")) - - @validate(String()) - def image_info(self, path, hdu=""): - """Returns metadata for the specified image file. - - This is mostly provided as an internal helper function. - - Parameters - ---------- - path : {0} - The path to the image file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. - - Returns - ------- - :obj:`carta.metadata.ImageInfo` object - The basic and extended metadata for this image. - """ - directory, file_name = posixpath.split(path) - file_info = self.call_action("backendService.getFileInfo", directory, file_name, hdu) - return ImageInfo(file_info["fileInfo"], file_info["fileInfoExtended"]) def active_frame(self): """Return the currently active image. diff --git a/carta/validation.py b/carta/validation.py index 95ff0d4..fd01a73 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -538,28 +538,18 @@ def description(self): return f"an iterable {size_desc}in which each element is {self.param.description}" -def MapOf(IterableOf): +class MapOf(IterableOf): """A dictionary of keys and values which must match the given descriptors. Parameters ---------- - key_param : :obj:`carta.validation.Parameter` - The key parameter descriptor. value_param : :obj:`carta.validation.Parameter` The value parameter descriptor. - min_size : integer, optional - The minimum size. - max_size : integer, optional - The maximum size. Attributes ---------- - param : :obj:`carta.validation.Parameter` - The parameter descriptor. - min_size : integer, optional - The minimum size. - max_size : integer, optional - The maximum size. + value_param : :obj:`carta.validation.Parameter` + The value parameter descriptor. """ def __init__(self, key_param, value_param, min_size=None, max_size=None): From 31d7a815c27844733c062a6d5d371a1730722560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Sat, 5 Aug 2023 00:33:08 +0200 Subject: [PATCH 11/16] Added some tests and performed a manual end-to-end test --- carta/image.py | 23 +++++---- carta/metadata.py | 23 ++++----- carta/session.py | 74 +++++++++++++++++++++++------ carta/validation.py | 12 ++--- setup.py | 2 +- tests/test_image.py | 10 ++-- tests/test_session.py | 106 ++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 200 insertions(+), 50 deletions(-) diff --git a/carta/image.py b/carta/image.py index 866115d..8d4fe58 100644 --- a/carta/image.py +++ b/carta/image.py @@ -19,8 +19,6 @@ class Image: 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_name : string - The file name of the image. This is not a full path. Attributes ---------- @@ -28,14 +26,11 @@ class Image: The session object associated with this image. image_id : integer The ID identifying this image within the session. - file_name : string - The file name of the image. """ - def __init__(self, session, image_id, file_name): + def __init__(self, session, image_id): self.session = session self.image_id = image_id - self.file_name = file_name self._base_path = f"frameMap[{image_id}]" self._frame = Macro("", self._base_path) @@ -79,7 +74,7 @@ def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_ params.append(update_directory) image_id = session.call_action(command, *params, return_path="frameInfo.fileId") - return cls(session, image_id, file_name) + return cls(session, image_id) @classmethod def from_list(cls, session, image_list): @@ -99,7 +94,7 @@ def from_list(cls, session, image_list): list of :obj:`carta.image.Image` A list of new image objects. """ - return [cls(session, f["value"], f["label"].split(":")[1].strip()) for f in image_list] + return [cls(session, f["value"]) for f in image_list] def __repr__(self): return f"{self.session.session_id}:{self.image_id}:{self.file_name}" @@ -164,6 +159,18 @@ def macro(self, target, variable): # METADATA + @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 directory(self): diff --git a/carta/metadata.py b/carta/metadata.py index 7c99f54..7676fb7 100644 --- a/carta/metadata.py +++ b/carta/metadata.py @@ -4,7 +4,8 @@ from .constants import Polarization -def parse_header(self, raw_header): + +def parse_header(raw_header): """Parse raw image header entries from the frontend into a more user-friendly format. Entries with T or F string values are automatically converted to booleans. @@ -14,7 +15,7 @@ def parse_header(self, raw_header): Adjacent ``COMMENT`` entries are not concatenated automatically. Any other header entries with no values are given values of ``None``. - + Parameters ---------- raw_header : dict @@ -78,43 +79,43 @@ def header_value(raw_entry): return header -def deduce_polarization(self, file_name, header): +def deduce_polarization(file_name, header): """Deduce the polarization of an image from its metadata. - + Parameters ---------- file_name : string The name of the image file. header : dict The parsed header of the image file (see :obj:`carta.metadata.parse_header`). - + Returns ------- :obj:`carta.constants.Polarization` The deduced polarization. - + Raises ------ ValueError If the polarization could not be deduced. """ polarization = None - + ctype_header = [k for k, v in header.items() if k.startswith("CTYPE") and v.upper() == "STOKES"] if ctype_header: index = ctype_header[0][5:] - naxis = header.get(f"NAXIS{index}", None) + naxis = header.get(f"NAXIS{index}", None) crpix = header.get(f"CRPIX{index}", None) crval = header.get(f"CRVAL{index}", None) cdelt = header.get(f"CDELT{index}", None) - + if all(naxis, crpix, crval, cdelt) and naxis == 1: polarization_index = crval + (1 - crpix) * cdelt try: return Polarization(polarization_index) except ValueError: pass - + if polarization is None: name_parts = re.split("[._]", file_name) matches = [] @@ -123,5 +124,5 @@ def deduce_polarization(self, file_name, header): matches.append(getattr(Polarization, part.upper())) if len(matches) == 1: return matches[0] - + raise ValueError(f"Could not deduce polarization from image file {file_name}.") diff --git a/carta/session.py b/carta/session.py index 83745ae..6858d61 100644 --- a/carta/session.py +++ b/carta/session.py @@ -357,6 +357,11 @@ def open_image(self, path, hdu="", append=False, make_active=True, update_direct Whether the image should be made active in the frontend. This only applies if an image is being appended. The default is ``True``. update_directory : {4} Whether the starting directory of the frontend file browser should be updated to the parent directory of the image. The default is ``False``. + + Returns + ------- + :obj:`carta.image.Image` + The opened image. """ directory, file_name = posixpath.split(path) return Image.new(self, directory, file_name, hdu, append, False, make_active=make_active, update_directory=update_directory) @@ -377,6 +382,11 @@ def open_complex_image(self, path, component=ComplexComponent.AMPLITUDE, append= Whether the image should be made active in the frontend. This only applies if an image is being appended. The default is ``True``. update_directory : {4} Whether the starting directory of the frontend file browser should be updated to the parent directory of the image. The default is ``False``. + + Returns + ------- + :obj:`carta.image.Image` + The opened image. """ directory, file_name = posixpath.split(path) expression = f'{component}("{file_name}")' @@ -398,10 +408,40 @@ def open_LEL_image(self, expression, directory=".", append=False, make_active=Tr Whether the image should be made active in the frontend. This only applies if an image is being appended. The default is ``True``. update_directory : {4} Whether the starting directory of the frontend file browser should be updated to the base directory of the LEL expression. The default is ``False``. + + Returns + ------- + :obj:`carta.image.Image` + The opened image. """ return Image.new(self, directory, expression, "", append, True, make_active=make_active, update_directory=update_directory) - - @validate(Union(IterableOf(String), MapOf(Constant(Polarization), String())), Boolean()) + + @validate(IterableOf(String()), Boolean()) + def open_images(self, image_paths, append=False): + """Open multiple images + + This is a utility function for adding multiple images in a single command. It assumes that the images are not complex-valued or LEL expressions, and that the default HDU can be used for each image. For more complicated use cases, the methods for opening individual images should be used. + + Parameters + ---------- + image_paths : {0} + The image paths, either relative to the session's current directory or absolute paths relative to the CARTA backend's root directory. + 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.image.Image` objects + The list of opened images. + """ + images = [] + for path in image_paths[:1]: + images.append(self.open_image(path, append=append)) + for path in image_paths[1:]: + images.append(self.open_image(path, append=True)) + return images + + @validate(Union(IterableOf(String(), min_size=2), MapOf(Constant(Polarization), String(), min_size=2)), Boolean()) def open_hypercube(self, image_paths, append=False): """Open multiple images merged into a polarization hypercube. @@ -410,41 +450,49 @@ def open_hypercube(self, image_paths, append=False): image_paths : {0} The image paths, either relative to the session's current directory or absolute paths relative to the CARTA backend's root directory. If this is a list of paths, the polarizations will be deduced from the image headers or names. If this is a dictionary, the polarizations must be used as keys. append : {1} - Whether the hypercube should be appended. Default is ``False``. - + Whether the hypercube should be appended to existing images. By default this is ``False`` and any existing open images are closed. + + Returns + ------- + :obj:`carta.image.Image` + The opened hypercube. + Raises ------ ValueError If explicit polarizations are not provided, and cannot be deduced from the image headers or names. """ stokes_images = [] - + if isinstance(image_paths, dict): for stokes, path in image_paths.items(): directory, file_name = posixpath.split(path) + directory = self.resolve_file_path(directory) stokes_images.append({"directory": directory, "file": file_name, "hdu": "", "polarizationType": stokes.proto_index}) else: for path in image_paths: directory, file_name = posixpath.split(path) - image_info = self.call_action("backendService.getFileInfo", directory, file_name, "") - header = parse_header(image_info["fileInfoExtended"]["0"]["headerEntries"]) + directory = self.resolve_file_path(directory) + raw_header = self.call_action("backendService.getFileInfo", directory, file_name, "", return_path="fileInfoExtended.0.headerEntries") + header = parse_header(raw_header) try: stokes = deduce_polarization(file_name, header) except ValueError: raise ValueError(f"Could not deduce polarization for {path}. Please use a dictionary to specify the polarization mapping explicitly.") stokes_images.append({"directory": directory, "file": file_name, "hdu": "", "polarizationType": stokes.proto_index}) - + output_directory = self.pwd() output_hdu = "" command = "appendConcatFile" if append else "openConcatFile" - self.call_action(command, stokes_images, output_directory, output_hdu) + image_id = self.call_action(command, stokes_images, output_directory, output_hdu) + return Image(self, image_id) def image_list(self): """Return the list of currently open images. Returns ------- - list of :obj:`carta.image.Image` objects. + list of :obj:`carta.image.Image` objects The list of images open in this session. """ return Image.from_list(self, self.get_value("frameNames")) @@ -457,10 +505,8 @@ def active_frame(self): :obj:`carta.image.Image` The currently active image. """ - frame_info = self.get_value("activeFrame.frameInfo") - image_id = frame_info["fileId"] - file_name = frame_info["fileInfo"]["name"] - return Image(self, image_id, file_name) + image_id = self.get_value("activeFrame.frameInfo.fileId") + return Image(self, image_id) def clear_spatial_reference(self): """Clear the spatial reference.""" diff --git a/carta/validation.py b/carta/validation.py index fd01a73..dcd19bc 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -536,7 +536,7 @@ def description(self): if size: size_desc = f"with {' and '.join(size)} " return f"an iterable {size_desc}in which each element is {self.param.description}" - + class MapOf(IterableOf): """A dictionary of keys and values which must match the given descriptors. @@ -565,11 +565,11 @@ def validate(self, value, parent): try: for v in value.values(): self.value_param.validate(v, parent) - except TypeError as e: - if str(e).endswith("object is not iterable"): - raise ValueError(f"{value} is not iterable, but {self.description} was expected.") + except AttributeError as e: + if str(e).endswith("has no attribute 'values'"): + raise ValueError(f"{value} is not a dictionary, but {self.description} was expected.") raise e - + super().validate(value, parent) @property @@ -581,7 +581,7 @@ def description(self): string The description. """ - + return re.sub("^an iterable (.*?)in which each element is (.*)$", rf"a dictionary \1in which each key is {self.param.description} and each value is {self.value_param.description}", super().description) diff --git a/setup.py b/setup.py index 28cca56..408149b 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="carta", - version="1.1.10", + version="1.1.11", author="Adrianna PiƄska", author_email="adrianna.pinska@gmail.com", description="CARTA scripting wrapper written in Python", diff --git a/tests/test_image.py b/tests/test_image.py index f89631b..8975a0b 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -22,7 +22,7 @@ def session(): def image(session): """Return an image object which uses the session fixture. """ - return Image(session, 0, "") + return Image(session, 0) @pytest.fixture @@ -47,7 +47,7 @@ def mock_session_call_action(session, mocker): def mock_property(mocker): """Return a helper function to mock the value of a decorated image property using a simple syntax.""" def func(property_name, mock_value): - mocker.patch(f"carta.image.Image.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) + return mocker.patch(f"carta.image.Image.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) return func @@ -55,7 +55,7 @@ def func(property_name, mock_value): def mock_method(image, mocker): """Return a helper function to mock the return value(s) of an image method using a simple syntax.""" def func(method_name, return_values): - mocker.patch.object(image, method_name, side_effect=return_values) + return mocker.patch.object(image, method_name, side_effect=return_values) return func @@ -63,7 +63,7 @@ def func(method_name, return_values): def mock_session_method(session, mocker): """Return a helper function to mock the return value(s) of a session method using a simple syntax.""" def func(method_name, return_values): - mocker.patch.object(session, method_name, side_effect=return_values) + return mocker.patch.object(session, method_name, side_effect=return_values) return func # TESTS @@ -130,12 +130,12 @@ def test_new(session, mock_session_call_action, mock_session_method, args, kwarg assert type(image_object) == Image assert image_object.session == session assert image_object.image_id == 123 - assert image_object.file_name == expected_params[2] # SIMPLE PROPERTIES TODO to be completed. @pytest.mark.parametrize("property_name,expected_path", [ + ("file_name", "frameInfo.fileInfo.name"), ("directory", "frameInfo.directory"), ("width", "frameInfo.fileInfoExtended.width"), ]) diff --git a/tests/test_session.py b/tests/test_session.py index 01b5bbb..6d17f1f 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,7 +4,7 @@ from carta.session import Session from carta.image import Image from carta.util import CartaValidationFailed, Macro -from carta.constants import CoordinateSystem, NumberFormat as NF, ComplexComponent as CC +from carta.constants import CoordinateSystem, NumberFormat as NF, ComplexComponent as CC, Polarization as Pol # FIXTURES @@ -34,7 +34,7 @@ def mock_call_action(session, mocker): def mock_property(mocker): """Return a helper function to mock the value of a decorated session property using a simple syntax.""" def func(property_name, mock_value): - mocker.patch(f"carta.session.Session.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) + return mocker.patch(f"carta.session.Session.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) return func @@ -42,7 +42,7 @@ def func(property_name, mock_value): def mock_method(session, mocker): """Return a helper function to mock the return value(s) of an session method using a simple syntax.""" def func(method_name, return_values): - mocker.patch.object(session, method_name, side_effect=return_values) + return mocker.patch.object(session, method_name, side_effect=return_values) return func @@ -134,8 +134,6 @@ def test_open_image(mocker, session, args, kwargs, expected_args, expected_kwarg session.open_image(*args, **kwargs) mock_image_new.assert_called_with(session, *expected_args, **expected_kwargs) -# TODO this should be merged with the test above when this separate function is removed - @pytest.mark.parametrize("args,kwargs,expected_args,expected_kwargs", [ # Open complex image with default component @@ -180,6 +178,104 @@ def test_open_LEL_image(mocker, session, args, kwargs, expected_args, expected_k mock_image_new.assert_called_with(session, *expected_args, **expected_kwargs) +@pytest.mark.parametrize("append", [True, False]) +def test_open_images(mocker, session, mock_method, append): + mock_open_image = mock_method("open_image", ["1", "2", "3"]) + images = session.open_images(["foo.fits", "bar.fits", "baz.fits"], append) + mock_open_image.assert_has_calls([ + mocker.call("foo.fits", append=append), + mocker.call("bar.fits", append=True), + mocker.call("baz.fits", append=True), + ]) + assert images == ["1", "2", "3"] + + +@pytest.mark.parametrize("paths,expected_args", [ + (["foo.fits", "bar.fits", "baz.fits"], [ + [ + {"directory": "/resolved/path", "file": "foo.fits", "hdu": "", "polarizationType": 1}, + {"directory": "/resolved/path", "file": "bar.fits", "hdu": "", "polarizationType": 2}, + {"directory": "/resolved/path", "file": "baz.fits", "hdu": "", "polarizationType": 3}, + ], "/current/dir", ""]), +]) +@pytest.mark.parametrize("append,expected_command", [ + (True, "appendConcatFile"), + (False, "openConcatFile"), +]) +def test_open_hypercube_guess_polarization(mocker, session, mock_call_action, mock_method, paths, expected_args, append, expected_command): + mock_pwd = mock_method("pwd", ["/current/dir"]) + mock_resolve = mock_method("resolve_file_path", ["/resolved/path"] * 3) + mock_call_action.side_effect = [[1], [2], [3], 123] + mock_parse_header = mocker.patch("carta.session.parse_header") + mock_parse_header.side_effect = [{1: 2}, {2: 3}, {3: 4}] + mock_deduce_polarization = mocker.patch("carta.session.deduce_polarization") + mock_deduce_polarization.side_effect = [Pol.I, Pol.Q, Pol.U] + + hypercube = session.open_hypercube(paths, append) + + # Not checking that parse_header and deduce_polarization called, because we're removing this bit anyway + + mock_resolve.assert_has_calls([mocker.call(""), mocker.call(""), mocker.call("")]) + mock_pwd.assert_called() + + # These header calls will also be removed + mock_call_action.assert_has_calls([ + mocker.call("backendService.getFileInfo", "/resolved/path", "foo.fits", "", return_path="fileInfoExtended.0.headerEntries"), + mocker.call("backendService.getFileInfo", "/resolved/path", "bar.fits", "", return_path="fileInfoExtended.0.headerEntries"), + mocker.call("backendService.getFileInfo", "/resolved/path", "baz.fits", "", return_path="fileInfoExtended.0.headerEntries"), + mocker.call(expected_command, *expected_args), + ]) + + assert type(hypercube) == Image + assert hypercube.session == session + assert hypercube.image_id == 123 + + +@pytest.mark.parametrize("paths,expected_args", [ + ({Pol.I: "foo.fits", Pol.Q: "bar.fits", Pol.U: "baz.fits"}, [ + [ + {"directory": "/resolved/path", "file": "foo.fits", "hdu": "", "polarizationType": 1}, + {"directory": "/resolved/path", "file": "bar.fits", "hdu": "", "polarizationType": 2}, + {"directory": "/resolved/path", "file": "baz.fits", "hdu": "", "polarizationType": 3}, + ], "/current/dir", ""]), +]) +@pytest.mark.parametrize("append,expected_command", [ + (True, "appendConcatFile"), + (False, "openConcatFile"), +]) +def test_open_hypercube_explicit_polarization(mocker, session, mock_call_action, mock_method, paths, expected_args, append, expected_command): + mock_pwd = mock_method("pwd", ["/current/dir"]) + mock_resolve = mock_method("resolve_file_path", ["/resolved/path"] * 3) + mock_call_action.side_effect = [123] + + hypercube = session.open_hypercube(paths, append) + + mock_resolve.assert_has_calls([mocker.call(""), mocker.call(""), mocker.call("")]) + mock_pwd.assert_called() + + mock_call_action.assert_has_calls([ + mocker.call(expected_command, *expected_args), + ]) + + assert type(hypercube) == Image + assert hypercube.session == session + assert hypercube.image_id == 123 + + +@pytest.mark.parametrize("paths,expected_error", [ + ({Pol.I: "foo.fits"}, "at least 2"), + (["foo.fits"], "at least 2"), +]) +@pytest.mark.parametrize("append", [True, False]) +def test_open_hypercube_bad(mocker, session, mock_call_action, mock_method, paths, expected_error, append): + mock_method("pwd", ["/current/dir"]) + mock_method("resolve_file_path", ["/resolved/path"] * 3) + + with pytest.raises(Exception) as e: + session.open_hypercube(paths, append) + assert expected_error in str(e.value) + + # OVERLAY From 6d40694252c34e4ad8446075c7bac7d3eb8bf823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Sat, 5 Aug 2023 00:37:51 +0200 Subject: [PATCH 12/16] Fixed warnings in updated flake8 --- tests/test_image.py | 2 +- tests/test_session.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_image.py b/tests/test_image.py index 8975a0b..1d0b11d 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -127,7 +127,7 @@ def test_new(session, mock_session_call_action, mock_session_method, args, kwarg mock_session_call_action.assert_called_with(*expected_params, return_path='frameInfo.fileId') - assert type(image_object) == Image + assert type(image_object) is Image assert image_object.session == session assert image_object.image_id == 123 diff --git a/tests/test_session.py b/tests/test_session.py index 6d17f1f..4276427 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -226,7 +226,7 @@ def test_open_hypercube_guess_polarization(mocker, session, mock_call_action, mo mocker.call(expected_command, *expected_args), ]) - assert type(hypercube) == Image + assert type(hypercube) is Image assert hypercube.session == session assert hypercube.image_id == 123 @@ -257,7 +257,7 @@ def test_open_hypercube_explicit_polarization(mocker, session, mock_call_action, mocker.call(expected_command, *expected_args), ]) - assert type(hypercube) == Image + assert type(hypercube) is Image assert hypercube.session == session assert hypercube.image_id == 123 From f5396e8bbd1efa87a981e9eb1585ced1a44be505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 10 Aug 2023 21:15:29 +0200 Subject: [PATCH 13/16] Removed blank line --- carta/metadata.py | 1 - 1 file changed, 1 deletion(-) diff --git a/carta/metadata.py b/carta/metadata.py index 2bba49e..7676fb7 100644 --- a/carta/metadata.py +++ b/carta/metadata.py @@ -16,7 +16,6 @@ def parse_header(raw_header): Any other header entries with no values are given values of ``None``. - Parameters ---------- raw_header : dict From 781372f993e2093f48b4efc32d71a55f6388f73e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 10 Aug 2023 21:16:06 +0200 Subject: [PATCH 14/16] removed module from docs --- docs/source/carta.rst | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/source/carta.rst b/docs/source/carta.rst index 7efa869..bda3ba5 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -57,14 +57,6 @@ carta.session module :undoc-members: :show-inheritance: -carta.structs module --------------------- - -.. automodule:: carta.structs - :members: - :undoc-members: - :show-inheritance: - carta.token module ------------------ From 3f3fcfa563b88f1b8b3779e450c14695eec01db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 18 Aug 2023 12:04:38 +0200 Subject: [PATCH 15/16] Removed custom stokes guessing function; using frontend function instead. Added check for duplicate deduced polarizations. --- carta/metadata.py | 53 ------------------------------------------- carta/session.py | 20 ++++++++++------ tests/test_session.py | 42 +++++++++++++++++++++++++--------- 3 files changed, 44 insertions(+), 71 deletions(-) diff --git a/carta/metadata.py b/carta/metadata.py index 7676fb7..aa6ca40 100644 --- a/carta/metadata.py +++ b/carta/metadata.py @@ -1,9 +1,5 @@ """This module provides a collection of helper objects for storing and accessing file metadata.""" -import re - -from .constants import Polarization - def parse_header(raw_header): """Parse raw image header entries from the frontend into a more user-friendly format. @@ -77,52 +73,3 @@ def header_value(raw_entry): header[""] = blank return header - - -def deduce_polarization(file_name, header): - """Deduce the polarization of an image from its metadata. - - Parameters - ---------- - file_name : string - The name of the image file. - header : dict - The parsed header of the image file (see :obj:`carta.metadata.parse_header`). - - Returns - ------- - :obj:`carta.constants.Polarization` - The deduced polarization. - - Raises - ------ - ValueError - If the polarization could not be deduced. - """ - polarization = None - - ctype_header = [k for k, v in header.items() if k.startswith("CTYPE") and v.upper() == "STOKES"] - if ctype_header: - index = ctype_header[0][5:] - naxis = header.get(f"NAXIS{index}", None) - crpix = header.get(f"CRPIX{index}", None) - crval = header.get(f"CRVAL{index}", None) - cdelt = header.get(f"CDELT{index}", None) - - if all(naxis, crpix, crval, cdelt) and naxis == 1: - polarization_index = crval + (1 - crpix) * cdelt - try: - return Polarization(polarization_index) - except ValueError: - pass - - if polarization is None: - name_parts = re.split("[._]", file_name) - matches = [] - for part in name_parts: - if hasattr(Polarization, part.upper()): - matches.append(getattr(Polarization, part.upper())) - if len(matches) == 1: - return matches[0] - - raise ValueError(f"Could not deduce polarization from image file {file_name}.") diff --git a/carta/session.py b/carta/session.py index 9b11fa9..07eb4be 100644 --- a/carta/session.py +++ b/carta/session.py @@ -15,7 +15,6 @@ from .protocol import Protocol from .util import logger, Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, OneOf, IterableOf, MapOf, Union -from .metadata import parse_header, deduce_polarization class Session: @@ -477,16 +476,23 @@ def open_hypercube(self, image_paths, append=False): directory = self.resolve_file_path(directory) stokes_images.append({"directory": directory, "file": file_name, "hdu": "", "polarizationType": stokes.proto_index}) else: + stokes_guesses = set() + for path in image_paths: directory, file_name = posixpath.split(path) directory = self.resolve_file_path(directory) - raw_header = self.call_action("backendService.getFileInfo", directory, file_name, "", return_path="fileInfoExtended.0.headerEntries") - header = parse_header(raw_header) - try: - stokes = deduce_polarization(file_name, header) - except ValueError: + + file_info_extended = self.call_action("backendService.getFileInfo", directory, file_name, "", return_path="fileInfoExtended") + stokes_guess = self.call_action("fileBrowserStore.getStokesType", list(file_info_extended.values())[0], file_name) + + if not stokes_guess: raise ValueError(f"Could not deduce polarization for {path}. Please use a dictionary to specify the polarization mapping explicitly.") - stokes_images.append({"directory": directory, "file": file_name, "hdu": "", "polarizationType": stokes.proto_index}) + + stokes_guesses.add(stokes_guess) + stokes_images.append({"directory": directory, "file": file_name, "hdu": "", "polarizationType": stokes_guess}) + + if len(stokes_guesses) < len(stokes_images): + raise ValueError("Duplicate polarizations deduced for provided images. Please use a dictionary to specify the polarization mapping explicitly.") output_directory = self.pwd() output_hdu = "" diff --git a/tests/test_session.py b/tests/test_session.py index 4276427..dfe3ca4 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -205,24 +205,20 @@ def test_open_images(mocker, session, mock_method, append): def test_open_hypercube_guess_polarization(mocker, session, mock_call_action, mock_method, paths, expected_args, append, expected_command): mock_pwd = mock_method("pwd", ["/current/dir"]) mock_resolve = mock_method("resolve_file_path", ["/resolved/path"] * 3) - mock_call_action.side_effect = [[1], [2], [3], 123] - mock_parse_header = mocker.patch("carta.session.parse_header") - mock_parse_header.side_effect = [{1: 2}, {2: 3}, {3: 4}] - mock_deduce_polarization = mocker.patch("carta.session.deduce_polarization") - mock_deduce_polarization.side_effect = [Pol.I, Pol.Q, Pol.U] + mock_call_action.side_effect = [{"0": "headers_foo"}, 1, {"0": "headers_bar"}, 2, {"0": "headers_baz"}, 3, 123] hypercube = session.open_hypercube(paths, append) - # Not checking that parse_header and deduce_polarization called, because we're removing this bit anyway - mock_resolve.assert_has_calls([mocker.call(""), mocker.call(""), mocker.call("")]) mock_pwd.assert_called() - # These header calls will also be removed mock_call_action.assert_has_calls([ - mocker.call("backendService.getFileInfo", "/resolved/path", "foo.fits", "", return_path="fileInfoExtended.0.headerEntries"), - mocker.call("backendService.getFileInfo", "/resolved/path", "bar.fits", "", return_path="fileInfoExtended.0.headerEntries"), - mocker.call("backendService.getFileInfo", "/resolved/path", "baz.fits", "", return_path="fileInfoExtended.0.headerEntries"), + mocker.call("backendService.getFileInfo", "/resolved/path", "foo.fits", "", return_path="fileInfoExtended"), + mocker.call("fileBrowserStore.getStokesType", "headers_foo", "foo.fits"), + mocker.call("backendService.getFileInfo", "/resolved/path", "bar.fits", "", return_path="fileInfoExtended"), + mocker.call("fileBrowserStore.getStokesType", "headers_bar", "bar.fits"), + mocker.call("backendService.getFileInfo", "/resolved/path", "baz.fits", "", return_path="fileInfoExtended"), + mocker.call("fileBrowserStore.getStokesType", "headers_baz", "baz.fits"), mocker.call(expected_command, *expected_args), ]) @@ -231,6 +227,30 @@ def test_open_hypercube_guess_polarization(mocker, session, mock_call_action, mo assert hypercube.image_id == 123 +@pytest.mark.parametrize("paths,expected_calls,mocked_side_effect,expected_error", [ + (["foo.fits", "bar.fits"], [ + (("backendService.getFileInfo", "/resolved/path", "foo.fits", ""), {"return_path": "fileInfoExtended"}), + (("fileBrowserStore.getStokesType", "headers_foo", "foo.fits"), {}), + ], [{"0": "headers_foo"}, 0], "Could not deduce polarization for"), + (["foo.fits", "bar.fits"], [ + (("backendService.getFileInfo", "/resolved/path", "foo.fits", ""), {"return_path": "fileInfoExtended"}), + (("fileBrowserStore.getStokesType", "headers_foo", "foo.fits"), {}), + (("backendService.getFileInfo", "/resolved/path", "bar.fits", ""), {"return_path": "fileInfoExtended"}), + (("fileBrowserStore.getStokesType", "headers_bar", "bar.fits"), {}), + ], [{"0": "headers_foo"}, 1, {"0": "headers_bar"}, 1], "Duplicate polarizations deduced"), +]) +def test_open_hypercube_guess_polarization_bad(mocker, session, mock_call_action, mock_method, paths, expected_calls, mocked_side_effect, expected_error): + mock_method("pwd", ["/current/dir"]) + mock_method("resolve_file_path", ["/resolved/path"] * 3) + mock_call_action.side_effect = mocked_side_effect + + with pytest.raises(ValueError) as e: + session.open_hypercube(paths) + assert expected_error in str(e.value) + + mock_call_action.assert_has_calls([mocker.call(*args, **kwargs) for (args, kwargs) in expected_calls]) + + @pytest.mark.parametrize("paths,expected_args", [ ({Pol.I: "foo.fits", Pol.Q: "bar.fits", Pol.U: "baz.fits"}, [ [ From dfd3791ff59ff8deabcc99a34dcb60c3371e82fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Wed, 23 Aug 2023 10:45:02 +0200 Subject: [PATCH 16/16] Adjust function to use simplified frontend API --- carta/session.py | 7 +++---- tests/test_session.py | 45 ++++++++++++++++++------------------------- 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/carta/session.py b/carta/session.py index 07eb4be..6ad26f8 100644 --- a/carta/session.py +++ b/carta/session.py @@ -482,14 +482,13 @@ def open_hypercube(self, image_paths, append=False): directory, file_name = posixpath.split(path) directory = self.resolve_file_path(directory) - file_info_extended = self.call_action("backendService.getFileInfo", directory, file_name, "", return_path="fileInfoExtended") - stokes_guess = self.call_action("fileBrowserStore.getStokesType", list(file_info_extended.values())[0], file_name) + stokes_guess = self.call_action("fileBrowserStore.getStokesFile", directory, file_name, "") if not stokes_guess: raise ValueError(f"Could not deduce polarization for {path}. Please use a dictionary to specify the polarization mapping explicitly.") - stokes_guesses.add(stokes_guess) - stokes_images.append({"directory": directory, "file": file_name, "hdu": "", "polarizationType": stokes_guess}) + stokes_guesses.add(stokes_guess["polarizationType"]) + stokes_images.append(stokes_guess) if len(stokes_guesses) < len(stokes_images): raise ValueError("Duplicate polarizations deduced for provided images. Please use a dictionary to specify the polarization mapping explicitly.") diff --git a/tests/test_session.py b/tests/test_session.py index dfe3ca4..824c434 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -203,22 +203,16 @@ def test_open_images(mocker, session, mock_method, append): (False, "openConcatFile"), ]) def test_open_hypercube_guess_polarization(mocker, session, mock_call_action, mock_method, paths, expected_args, append, expected_command): - mock_pwd = mock_method("pwd", ["/current/dir"]) - mock_resolve = mock_method("resolve_file_path", ["/resolved/path"] * 3) - mock_call_action.side_effect = [{"0": "headers_foo"}, 1, {"0": "headers_bar"}, 2, {"0": "headers_baz"}, 3, 123] + mock_method("pwd", ["/current/dir"]) + mock_method("resolve_file_path", ["/resolved/path"] * 3) + mock_call_action.side_effect = [*expected_args[0], 123] hypercube = session.open_hypercube(paths, append) - mock_resolve.assert_has_calls([mocker.call(""), mocker.call(""), mocker.call("")]) - mock_pwd.assert_called() - mock_call_action.assert_has_calls([ - mocker.call("backendService.getFileInfo", "/resolved/path", "foo.fits", "", return_path="fileInfoExtended"), - mocker.call("fileBrowserStore.getStokesType", "headers_foo", "foo.fits"), - mocker.call("backendService.getFileInfo", "/resolved/path", "bar.fits", "", return_path="fileInfoExtended"), - mocker.call("fileBrowserStore.getStokesType", "headers_bar", "bar.fits"), - mocker.call("backendService.getFileInfo", "/resolved/path", "baz.fits", "", return_path="fileInfoExtended"), - mocker.call("fileBrowserStore.getStokesType", "headers_baz", "baz.fits"), + mocker.call("fileBrowserStore.getStokesFile", "/resolved/path", "foo.fits", ""), + mocker.call("fileBrowserStore.getStokesFile", "/resolved/path", "bar.fits", ""), + mocker.call("fileBrowserStore.getStokesFile", "/resolved/path", "baz.fits", ""), mocker.call(expected_command, *expected_args), ]) @@ -229,15 +223,17 @@ def test_open_hypercube_guess_polarization(mocker, session, mock_call_action, mo @pytest.mark.parametrize("paths,expected_calls,mocked_side_effect,expected_error", [ (["foo.fits", "bar.fits"], [ - (("backendService.getFileInfo", "/resolved/path", "foo.fits", ""), {"return_path": "fileInfoExtended"}), - (("fileBrowserStore.getStokesType", "headers_foo", "foo.fits"), {}), - ], [{"0": "headers_foo"}, 0], "Could not deduce polarization for"), + ("fileBrowserStore.getStokesFile", "/resolved/path", "foo.fits", ""), + ], [ + None, + ], "Could not deduce polarization for"), (["foo.fits", "bar.fits"], [ - (("backendService.getFileInfo", "/resolved/path", "foo.fits", ""), {"return_path": "fileInfoExtended"}), - (("fileBrowserStore.getStokesType", "headers_foo", "foo.fits"), {}), - (("backendService.getFileInfo", "/resolved/path", "bar.fits", ""), {"return_path": "fileInfoExtended"}), - (("fileBrowserStore.getStokesType", "headers_bar", "bar.fits"), {}), - ], [{"0": "headers_foo"}, 1, {"0": "headers_bar"}, 1], "Duplicate polarizations deduced"), + ("fileBrowserStore.getStokesFile", "/resolved/path", "foo.fits", ""), + ("fileBrowserStore.getStokesFile", "/resolved/path", "bar.fits", ""), + ], [ + {"directory": "/resolved/path", "file": "foo.fits", "hdu": "", "polarizationType": 1}, + {"directory": "/resolved/path", "file": "bar.fits", "hdu": "", "polarizationType": 1}, + ], "Duplicate polarizations deduced"), ]) def test_open_hypercube_guess_polarization_bad(mocker, session, mock_call_action, mock_method, paths, expected_calls, mocked_side_effect, expected_error): mock_method("pwd", ["/current/dir"]) @@ -248,7 +244,7 @@ def test_open_hypercube_guess_polarization_bad(mocker, session, mock_call_action session.open_hypercube(paths) assert expected_error in str(e.value) - mock_call_action.assert_has_calls([mocker.call(*args, **kwargs) for (args, kwargs) in expected_calls]) + mock_call_action.assert_has_calls([mocker.call(*args) for args in expected_calls]) @pytest.mark.parametrize("paths,expected_args", [ @@ -264,15 +260,12 @@ def test_open_hypercube_guess_polarization_bad(mocker, session, mock_call_action (False, "openConcatFile"), ]) def test_open_hypercube_explicit_polarization(mocker, session, mock_call_action, mock_method, paths, expected_args, append, expected_command): - mock_pwd = mock_method("pwd", ["/current/dir"]) - mock_resolve = mock_method("resolve_file_path", ["/resolved/path"] * 3) + mock_method("pwd", ["/current/dir"]) + mock_method("resolve_file_path", ["/resolved/path"] * 3) mock_call_action.side_effect = [123] hypercube = session.open_hypercube(paths, append) - mock_resolve.assert_has_calls([mocker.call(""), mocker.call(""), mocker.call("")]) - mock_pwd.assert_called() - mock_call_action.assert_has_calls([ mocker.call(expected_command, *expected_args), ])