diff --git a/.gitignore b/.gitignore index 6deda96..01da19d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__ build carta.egg-info .DS_Store +.coverage diff --git a/README.md b/README.md index 69a008f..f75b960 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,26 @@ To create a new frontend session which is controlled by the wrapper instead of c Some example usage of the client as a module is shown in the [documentation](https://carta-python.readthedocs.io). The client is under rapid development and this API should be considered experimental and subject to change depending on feedback. The current overall design principle considers session and image objects to be lightweight conduits to the frontend. They store as little state as possible and are not guaranteed to be unique or valid connections -- it is the caller's responsibility to manage the objects and store retrieved data as required. + +Unit tests +---------- + +Running the unit tests requires the installation of additional dependencies: +``` +pip install pytest +pip install pytest-mock +pip install pytest-cov +``` + +To run all the unit tests (from the root directory of the repository): +``` +pytest tests # concise +pytest -v tests # more verbose +``` + +To view the code coverage: +``` +pytest --cov=carta tests/ +``` + +See the [`pytest` documentation](https://docs.pytest.org/) for more usage options. diff --git a/carta/constants.py b/carta/constants.py index 57eb9d0..42dfafe 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -1,6 +1,6 @@ """This module provides a collection of enums corresponding to various enumerated types and other literal lists of options defined in the frontend. The members of these enums should be used in place of literal strings and numbers to represent these values; for example: ``Colormap.VIRIDIS`` rather than ``"viridis"``. """ -from enum import Enum, IntEnum +from enum import Enum, IntEnum, auto # TODO make sure the __str__ is right for all the string values @@ -21,10 +21,23 @@ class ArithmeticExpression(str, Enum): Scaling.__doc__ = """Colormap scaling types.""" -CoordinateSystem = Enum('CoordinateSystem', {c.upper(): c for c in ("Auto", "Ecliptic", "FK4", "FK5", "Galactic", "ICRS")}, type=str) +CoordinateSystem = Enum('CoordinateSystem', {c: c for c in ("AUTO", "ECLIPTIC", "FK4", "FK5", "GALACTIC", "ICRS")}, type=str) CoordinateSystem.__doc__ = """Coordinate systems.""" +class NumberFormat(str, Enum): + """Number formats.""" + DEGREES = "d" + HMS = "hms" + DMS = "dms" + + +class SpatialAxis(str, Enum): + """Spatial axes.""" + X = "x" + Y = "y" + + class LabelType(str, Enum): """Label types.""" INTERIOR = "Interior" diff --git a/carta/image.py b/carta/image.py index f5ade06..c1e142b 100644 --- a/carta/image.py +++ b/carta/image.py @@ -4,9 +4,9 @@ """ import posixpath -from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization -from .util import Macro, cached -from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf +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 class Image: @@ -261,7 +261,7 @@ def shape(self): @property @cached def width(self): - """The width of the image. + """The width of the image in pixels. Returns ------- @@ -273,7 +273,7 @@ def width(self): @property @cached def height(self): - """The height of the image. + """The height of the image in pixels. Returns ------- @@ -426,11 +426,23 @@ def set_polarization(self, polarization, recursive=True): self.call_action("setChannels", self.macro("", "requiredChannel"), polarization, recursive) - @validate(Number(), Number()) - def set_center(self, x, y): - """Set the center position. + @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") - TODO: what are the units? + @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. + + Coordinates must either both be image coordinates or match the current number formats. Numbers and numeric strings with no units are interpreted as degrees. Parameters ---------- @@ -438,11 +450,67 @@ def set_center(self, x, y): The X position. y : {1} The Y position. + system : {2} + The coordinate system. If this parameter is provided, the coordinate system will be changed session-wide before the X and Y coordinates are parsed. + + 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 format. """ - self.call_action("setCenter", x, y) + if system is not None: + self.session.set_coordinate_system(system) + + x_is_pixel = PixelValue.valid(str(x)) + y_is_pixel = PixelValue.valid(str(y)) + + if x_is_pixel and y_is_pixel: + # Image coordinates + x_value = PixelValue.as_float(str(x)) + y_value = PixelValue.as_float(str(y)) + self.call_action("setCenter", x_value, y_value) + + elif x_is_pixel or y_is_pixel: + raise ValueError("Cannot mix image and world coordinates.") + + else: + if not self.valid_wcs: + raise ValueError("Cannot parse world coordinates. This image does not contain valid WCS information. Please use image coordinates (in pixels) instead.") + + number_format_x, number_format_y, _ = self.session.number_format() + x_value = WorldCoordinate.with_format(number_format_x).from_string(str(x), SpatialAxis.X) + y_value = WorldCoordinate.with_format(number_format_y).from_string(str(y), SpatialAxis.Y) + self.call_action("setCenterWcs", str(x_value), str(y_value)) + + @validate(Size(), Constant(SpatialAxis)) + def zoom_to_size(self, size, axis): + """Zoom to the given size along the specified axis. + + Numbers and 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 world coordinates are provided and the image has no valid WCS information. + """ + size = str(size) + + if PixelValue.valid(size): + self.call_action(f"zoomToSize{axis.upper()}", PixelValue.as_float(size)) + else: + if not self.valid_wcs: + raise ValueError("Cannot parse angular size. This image does not contain valid WCS information. Please use a pixel size instead.") + self.call_action(f"zoomToSize{axis.upper()}Wcs", str(AngularSize.from_string(size))) @validate(Number(), Boolean()) - def set_zoom(self, zoom, absolute=True): + def set_zoom_level(self, zoom, absolute=True): """Set the zoom level. TODO: explain this more rigorously. diff --git a/carta/session.py b/carta/session.py index 0ba2e22..f649251 100644 --- a/carta/session.py +++ b/carta/session.py @@ -9,7 +9,7 @@ import base64 from .image import Image -from .constants import CoordinateSystem, LabelType, BeamType, PaletteColor, Overlay, PanelMode, GridMode, ArithmeticExpression +from .constants import CoordinateSystem, LabelType, BeamType, PaletteColor, Overlay, PanelMode, GridMode, ArithmeticExpression, NumberFormat from .backend import Backend from .protocol import Protocol from .util import logger, Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl @@ -495,6 +495,16 @@ def set_coordinate_system(self, system=CoordinateSystem.AUTO): """ self.call_action("overlayStore.global.setSystem", system) + def coordinate_system(self): + """Get the coordinate system. + + Returns + ---------- + :obj:`carta.constants.CoordinateSystem` + The coordinate system. + """ + return CoordinateSystem(self.get_value("overlayStore.global.system")) + @validate(Constant(LabelType)) def set_label_type(self, label_type): """Set the label type. @@ -554,6 +564,44 @@ def set_font(self, component, font=None, font_size=None): if font_size is not None: self.call_action(f"overlayStore.{component}.setFontSize", font_size) + @validate(NoneOr(Constant(NumberFormat)), NoneOr(Constant(NumberFormat))) + def set_custom_number_format(self, x_format=None, y_format=None): + """Set a custom X and Y number format. + + Parameters + ---------- + x_format : {0} + The X format. If this is unset, the last custom X format to be set will be restored. + x_format : {1} + The Y format. If this is unset, the last custom Y format to be set will be restored. + """ + if x_format is not None: + self.call_overlay_action(Overlay.NUMBERS, "setFormatX", x_format) + if y_format is not None: + self.call_overlay_action(Overlay.NUMBERS, "setFormatY", y_format) + self.call_overlay_action(Overlay.NUMBERS, "setCustomFormat", True) + + def clear_custom_number_format(self): + """Disable the custom X and Y number format.""" + self.call_overlay_action(Overlay.NUMBERS, "setCustomFormat", False) + + def number_format(self): + """Return the current X and Y number formats, and whether they are a custom setting. + + If the image has no WCS information, both the X and Y formats will be ``None``. + + If a custom number format is not set, the format is derived from the coordinate system. + + Returns + ------- + tuple (a member of :obj:`carta.constants.NumberFormat` or ``None``, a member of :obj:`carta.constants.NumberFormat` or ``None``, boolean) + A tuple containing the X format, the Y format, and whether a custom format is set. + """ + number_format_x = self.get_overlay_value(Overlay.NUMBERS, "formatTypeX") + number_format_y = self.get_overlay_value(Overlay.NUMBERS, "formatTypeY") + custom_format = self.get_overlay_value(Overlay.NUMBERS, "customFormat") + return NumberFormat(number_format_x), NumberFormat(number_format_y), custom_format + @validate(NoneOr(Constant(BeamType)), NoneOr(Number()), NoneOr(Number()), NoneOr(Number())) def set_beam(self, beam_type=None, width=None, shift_x=None, shift_y=None): """Set the beam properties. diff --git a/carta/util.py b/carta/util.py index 7d31bd2..a213266 100644 --- a/carta/util.py +++ b/carta/util.py @@ -4,6 +4,9 @@ import json import functools import re +import math + +from .constants import NumberFormat, SpatialAxis logger = logging.getLogger("carta_scripting") logger.setLevel(logging.WARN) @@ -85,6 +88,9 @@ def __init__(self, target, variable): def __repr__(self): return f"Macro('{self.target}', '{self.variable}')" + def __eq__(self, other): + return repr(self) == repr(other) + def json(self): """The JSON serialization of this object.""" return {"macroTarget": self.target, "macroVariable": self.variable} @@ -129,3 +135,425 @@ def split_action_path(path): """ parts = path.split('.') return '.'.join(parts[:-1]), parts[-1] + + +class PixelValue: + """Parses pixel values.""" + + UNITS = {"px", "pix", "pixel", "pixels"} + UNIT_REGEX = rf"^(-?\d+(?:\.\d+)?)\s*(?:{'|'.join(UNITS)})$" + + @classmethod + def valid(cls, value): + """Whether the input string is a numeric value followed by a pixel unit. + + Permitted pixel unit strings are stored in :obj:`carta.util.PixelValue.UNITS`. Whitespace is permitted after the number and before the unit. Pixel values may be negative. + + Parameters + ---------- + value : string + The input string. + + Returns + ------- + boolean + Whether the input string is a pixel value. + """ + m = re.match(cls.UNIT_REGEX, value, re.IGNORECASE) + return m is not None + + @classmethod + def as_float(cls, value): + """Parse a string containing a numeric value followed by a pixel unit, and return the numeric part as a float. + + Permitted pixel unit strings are stored in :obj:`carta.util.PixelValue.UNITS`. Whitespace is permitted after the number and before the unit. + + Parameters + ---------- + value : string + The string representation of the pixel value. + + Returns + ------- + float + The numeric portion of the pixel value. + + Raises + ------ + ValueError + If the input string is not in a recognized format. + """ + m = re.match(cls.UNIT_REGEX, value, re.IGNORECASE) + if m is None: + raise ValueError(f"{repr(value)} is not in a recognized pixel format.") + return float(m.group(1)) + + +class AngularSize: + """An angular size. + + This class provides methods for parsing angular sizes with any known unit, and should not be instantiated directly. Child classes can be used directly if the unit is known. + + Child class instances have a string representation in a normalized format which can be parsed by the frontend. + """ + FORMATS = {} + NAME = "angular size" + + def __init__(self, value): + self.value = value + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls._update_unit_regex(cls.INPUT_UNITS) + + for unit in cls.INPUT_UNITS: + AngularSize.FORMATS[unit] = cls + + AngularSize._update_unit_regex(AngularSize.FORMATS.keys()) + + @classmethod + def _update_unit_regex(cls, units): + """Update the unit regexes using the provided unit set.""" + symbols = {u for u in units if len(u) <= 1} + words = units - symbols + + cls.SYMBOL_UNIT_REGEX = rf"^(\d+(?:\.\d+)?)({'|'.join(symbols)})$" + cls.WORD_UNIT_REGEX = rf"^(\d+(?:\.\d+)?)\s*({'|'.join(words)})$" + + @classmethod + def valid(cls, value): + """Whether the input string is a numeric value followed by an angular size unit. + + A number without a unit is assumed to be in arcseconds. Permitted unit strings and their mappings to normalized units are stored in subclasses of :obj:`carta.util.AngularSize`. Whitespace is permitted after the number and before a unit which is a word, but not before a single-character unit. + + This method may also be used from child classes if a specific format is required. + + Parameters + ---------- + value : string + The input string. + + Returns + ------- + boolean + Whether the input string is an angular size. + """ + return any((re.match(cls.WORD_UNIT_REGEX, value, re.IGNORECASE), re.match(cls.SYMBOL_UNIT_REGEX, value, re.IGNORECASE))) + + @classmethod + def from_string(cls, value): + """Construct an angular size object from a string. + + A number without a unit is assumed to be in arcseconds. Permitted unit strings and their mappings to normalized units are stored in subclasses of :obj:`carta.util.AngularSize`. Whitespace is permitted after the number and before a unit which is a word, but not before a single-character unit. + + This method may also be used from child classes if a specific format is required. + + Parameters + ---------- + value : string + The string representation of the angular size. + + Returns + ------- + :obj:`carta.util.AngularSize` + The angular size object. + + Raises + ------ + ValueError + If the angular size string is not in a recognized format. + """ + m = re.match(cls.WORD_UNIT_REGEX, value, re.IGNORECASE) + if m is None: + m = re.match(cls.SYMBOL_UNIT_REGEX, value, re.IGNORECASE) + if m is None: + raise ValueError(f"{repr(value)} is not in a recognized {cls.NAME} format.") + value, unit = m.groups() + if cls is AngularSize: + return cls.FORMATS[unit](float(value)) + return cls(float(value)) + + def __str__(self): + if type(self) is AngularSize: + raise NotImplementedError() + value = self.value * self.FACTOR + return f"{value:g}{self.OUTPUT_UNIT}" + + +class DegreesSize(AngularSize): + """An angular size in degrees.""" + NAME = "degree" + INPUT_UNITS = {"deg", "degree", "degrees"} + OUTPUT_UNIT = "deg" + FACTOR = 1 + + +class ArcminSize(AngularSize): + """An angular size in arcminutes.""" + NAME = "arcminute" + INPUT_UNITS = {"'", "arcminutes", "arcminute", "arcmin", "amin", "′"} + OUTPUT_UNIT = "'" + FACTOR = 1 + + +class ArcsecSize(AngularSize): + """An angular size in arcseconds.""" + NAME = "arcsecond" + INPUT_UNITS = {"\"", "", "arcseconds", "arcsecond", "arcsec", "asec", "″"} + OUTPUT_UNIT = "\"" + FACTOR = 1 + + +class MilliarcsecSize(AngularSize): + """An angular size in milliarcseconds.""" + NAME = "milliarcsecond" + INPUT_UNITS = {"milliarcseconds", "milliarcsecond", "milliarcsec", "mas"} + OUTPUT_UNIT = "\"" + FACTOR = 1e-3 + + +class MicroarcsecSize(AngularSize): + """An angular size in microarcseconds.""" + NAME = "microarcsecond" + INPUT_UNITS = {"microarcseconds", "microarcsecond", "microarcsec", "µas", "uas"} + OUTPUT_UNIT = "\"" + FACTOR = 1e-6 + + +class WorldCoordinate: + """A world coordinate.""" + + FMT = None + FORMATS = {} + + def __init_subclass__(cls, **kwargs): + """Automatically register subclasses corresponding to number formats.""" + super().__init_subclass__(**kwargs) + if isinstance(cls.FMT, NumberFormat): + super(cls, cls).FORMATS[cls.FMT] = cls + + @classmethod + def valid(cls, value): + """Whether the input string is a world coordinate string in any of the recognised formats. + + Coordinates may be provided in HMS or DMS format (with colons or letters as separators), or in degrees (with or without an explicit unit). Permitted degree unit strings are stored in :obj:`carta.util.DegreesCoordinate.DEGREE_UNITS`. + + Parameters + ---------- + value : string + The input string. + + Returns + ------- + boolean + Whether the input string is a valid world coordinate. + """ + if cls is WorldCoordinate: + return any(fmt.valid(value) for fmt in cls.FORMATS.values()) + return any(re.match(exp, value, re.IGNORECASE) for exp in cls.REGEX.values()) + + @classmethod + def with_format(cls, fmt): + """Return the subclass of :obj:`carta.util.WorldCoordinate` corresponding to the specified format.""" + if isinstance(fmt, NumberFormat): + return cls.FORMATS[fmt] + raise ValueError(f"Unknown number format: {fmt}") + + @classmethod + def from_string(cls, value, axis): + """Construct a world coordinate object from a string. + + This is implemented in subclasses corresponding to different formats. + + Parameters + ---------- + value : string + The input string. + axis : :obj:`carta.constants.SpatialAxis` + The spatial axis of this coordinate. + + Returns + ------- + :obj:`carta.util.WorldCoordinate` + The coordinate object. + """ + raise NotImplementedError() + + +class DegreesCoordinate(WorldCoordinate): + """A world coordinate in decimal degree format.""" + FMT = NumberFormat.DEGREES + DEGREE_UNITS = DegreesSize.INPUT_UNITS + REGEX = { + "DEGREE_UNIT": rf"^-?(\d+(?:\.\d+)?)\s*({'|'.join(DEGREE_UNITS)})$", + "DECIMAL": r"^-?\d+(\.\d+)?$", + } + + @classmethod + def from_string(cls, value, axis): + """Construct a world coordinate object in decimal degree format from a string. + + Coordinates may be provided with or without an explicit unit. Permitted degree unit strings are stored in :obj:`carta.util.DegreesCoordinate.DEGREE_UNITS`. + + Parameters + ---------- + value : string + The input string. + axis : :obj:`carta.constants.SpatialAxis` + The spatial axis of this coordinate. + + Returns + ------- + :obj:`carta.util.DegreesCoordinate` + The coordinate object. + """ + m = re.match(cls.REGEX["DECIMAL"], value, re.IGNORECASE) + if m is not None: + fvalue = float(value) + else: + m = re.match(cls.REGEX["DEGREE_UNIT"], value, re.IGNORECASE) + if m is not None: + fvalue = float(m.group(1)) + else: + raise ValueError(f"Coordinate string {value} does not match expected format {cls.FMT}.") + + if axis == SpatialAxis.X and not 0 <= fvalue < 360: + raise ValueError(f"Degrees coordinate string {value} is outside the permitted longitude range [0, 360).") + if axis == SpatialAxis.Y and not -90 <= fvalue <= 90: + raise ValueError(f"Degrees coordinate string {value} is outside the permitted latitude range [-90, 90].") + + return cls(fvalue) + + def __init__(self, degrees): + self.degrees = degrees + + def __str__(self): + return f"{self.degrees:g}" + + +class SexagesimalCoordinate(WorldCoordinate): + """A world coordinate in sexagesimal format. + + This class contains common functionality for parsing the HMS and DMS formats. + """ + + @classmethod + def from_string(cls, value, axis): + """Construct a world coordinate object in sexagesimal format from a string. + + Coordinates may be provided in HMS or DMS format with colons or letters as separators. The value range will be validated for the provided spatial axis. + + Parameters + ---------- + value : string + The input string. + axis : :obj:`carta.constants.SpatialAxis` + The spatial axis of this coordinate. + + Returns + ------- + :obj:`carta.util.SexagesimalCoordinate` + The coordinate object. + """ + def to_float(strs): + return tuple(0 if s is None else float(s) for s in strs) + + m = re.match(cls.REGEX["COLON"], value, re.IGNORECASE) + if m is not None: + return cls(*to_float(m.groups())) + + m = re.match(cls.REGEX["LETTER"], value, re.IGNORECASE) + if m is not None: + return cls(*to_float(m.groups())) + + raise ValueError(f"Coordinate string {value} does not match expected format {cls.FMT}.") + + def __init__(self, hours_or_degrees, minutes, seconds): + self.hours_or_degrees = hours_or_degrees + self.minutes = minutes + self.seconds = seconds + + def __str__(self): + fractional_seconds, whole_seconds = math.modf(self.seconds) + fraction_string = f"{fractional_seconds:g}".lstrip("0") if fractional_seconds else "" + return f"{self.hours_or_degrees:g}:{self.minutes:0>2.0f}:{whole_seconds:0>2.0f}{fraction_string}" + + def as_tuple(self): + return self.hours_or_degrees, self.minutes, self.seconds + + +class HMSCoordinate(SexagesimalCoordinate): + """A world coordinate in HMS format.""" + FMT = NumberFormat.HMS + # Temporarily allow negative H values to account for frontend custom format oddity + REGEX = { + "COLON": r"^(-?(?:\d|[01]\d|2[0-3]))?:([0-5]?\d)?:([0-5]?\d(?:\.\d+)?)?$", + "LETTER": r"^(?:(-?(?:\d|[01]\d|2[0-3]))h)?(?:([0-5]?\d)m)?(?:([0-5]?\d(?:\.\d+)?)s)?$", + } + + @classmethod + def from_string(cls, value, axis): + """Construct a world coordinate object in HMS format from a string. + + Coordinates may be provided in HMS format with colons or letters as separators. The value range will be validated for the provided spatial axis. + + Parameters + ---------- + value : string + The input string. + axis : :obj:`carta.constants.SpatialAxis` + The spatial axis of this coordinate. + + Returns + ------- + :obj:`carta.util.HMSCoordinate` + The coordinate object. + """ + H, M, S = super().from_string(value, axis).as_tuple() + + if axis == SpatialAxis.X and not 0 <= H < 24: + raise ValueError(f"HMS coordinate string {value} is outside the permitted longitude range [0:00:00, 24:00:00).") + + if axis == SpatialAxis.Y: # Temporary; we can make this whole option invalid + if H < -6 or H > 6 or ((H in (-6, 6)) and (M or S)): + raise ValueError(f"HMS coordinate string {value} is outside the permitted latitude range [-6:00:00, 6:00:00].") + + return cls(H, M, S) + + +class DMSCoordinate(SexagesimalCoordinate): + """A world coordinate in DMS format.""" + FMT = NumberFormat.DMS + REGEX = { + "COLON": r"^(-?\d+)?:([0-5]?\d)?:([0-5]?\d(?:\.\d+)?)?$", + "LETTER": r"^(?:(-?\d+)d)?(?:([0-5]?\d)m)?(?:([0-5]?\d(?:\.\d+)?)s)?$", + } + + @classmethod + def from_string(cls, value, axis): + """Construct a world coordinate object in DMS format from a string. + + Coordinates may be provided in DMS format with colons or letters as separators. The value range will be validated for the provided spatial axis. + + Parameters + ---------- + value : string + The input string. + axis : :obj:`carta.constants.SpatialAxis` + The spatial axis of this coordinate. + + Returns + ------- + :obj:`carta.util.DMSCoordinate` + The coordinate object. + """ + D, M, S = super().from_string(value, axis).as_tuple() + + if axis == SpatialAxis.X and not 0 <= D < 360: + raise ValueError(f"DMS coordinate string {value} is outside the permitted longitude range [0:00:00, 360:00:00).") + + if axis == SpatialAxis.Y: + if D < -90 or D > 90 or ((D in (-90, 90)) and (M or S)): + raise ValueError(f"DMS coordinate string {value} is outside the permitted latitude range [-90:00:00, 90:00:00].") + + return cls(D, M, S) diff --git a/carta/validation.py b/carta/validation.py index f29f045..ea0e928 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -4,7 +4,7 @@ import functools import inspect -from .util import CartaValidationFailed +from .util import CartaValidationFailed, PixelValue, AngularSize, WorldCoordinate class Parameter: @@ -91,20 +91,20 @@ class String(Parameter): ---------- regex : str, optional A regular expression string which the parameter must match. - ignorecase : bool, optional - Whether the regular expression match should be case-insensitive. + flags : int, optional + The flags to use when matching the regular expression. Set to zero (no flags) by default. Attributes ---------- regex : str A regular expression string which the parameter must match. flags : int - The flags to use when matching the regular expression. This is set to :obj:`re.IGNORECASE` or zero. + The flags to use when matching the regular expression. """ - def __init__(self, regex=None, ignorecase=False): + def __init__(self, regex=None, flags=0): self.regex = regex - self.flags = re.IGNORECASE if ignorecase else 0 + self.flags = flags def validate(self, value, parent): """Check if the value is a string and if it matches a regex if one was provided. @@ -190,7 +190,7 @@ def validate(self, value, parent): See :obj:`carta.validation.Parameter.validate` for general information about this method. """ try: - float(value) + float(value) # TODO: this will allow strings and probably other types, but they will fail below. Coerce to float?? except TypeError: raise TypeError(f"{value} has type {type(value)} but a number was expected.") @@ -622,6 +622,54 @@ def __init__(self): super().__init__(*options, description="an HTML color specification") +class Size(Union): + """A representation of an angular size or a size in pixels. Can be a number or a numeric string with valid size units. Validates strings using :obj:`carta.util.PixelValue` and :obj:`carta.util.AngularSize`.""" + + class PixelValue(String): + """Helper validator class which uses :obj:`carta.util.PixelValue` to validate strings.""" + + def validate(self, value, parent): + super().validate(value, parent) + if not PixelValue.valid(value): + raise ValueError(f"{value} is not a pixel value.") + + class AngularSize(String): + """Helper validator class which uses :obj:`carta.util.AngularSize` to validate strings.""" + + def validate(self, value, parent): + super().validate(value, parent) + if not AngularSize.valid(value): + raise ValueError(f"{value} is not an angular size.") + + def __init__(self): + options = ( + Number(), + self.PixelValue(), + self.AngularSize(), + ) + super().__init__(*options, description="a number or a numeric string with valid size units") + + +class Coordinate(Union): + """A string representation of a world coordinate or image coordinate. Can be a number, a string in H:M:S or D:M:S format, or a numeric string with degree units or pixel units. Validates strings using :obj:`carta.util.PixelValue` and :obj:`carta.util.WorldCoordinate`.""" + + class WorldCoordinate(String): + """Helper validator class which uses :obj:`carta.util.WorldCoordinate` to validate strings.""" + + def validate(self, value, parent): + super().validate(value, parent) + if not WorldCoordinate.valid(value): + raise ValueError(f"{value} is not a world coordinate.") + + def __init__(self): + options = ( + Number(), + Size.PixelValue(), + self.WorldCoordinate(), + ) + super().__init__(*options, description="a number, a string in H:M:S or D:M:S format, or a numeric string with degree units or pixel units") + + class Attr(str): """A wrapper for arguments to be passed to the :obj:`carta.validation.Evaluate` descriptor. These arguments are string names of properties on the parent object of the decorated method, which will be evaluated at runtime.""" pass diff --git a/scripts/style_check.sh b/scripts/style_check.sh index be686c6..173fabe 100755 --- a/scripts/style_check.sh +++ b/scripts/style_check.sh @@ -2,4 +2,4 @@ # Check for PEP8 code style violations, but ignore long lines and ambiguous variable names -pycodestyle --ignore=E501,E741 carta/*.py +pycodestyle --ignore=E501,E741 carta/*.py tests/*.py diff --git a/scripts/style_fix.sh b/scripts/style_fix.sh index 10d05eb..8775920 100755 --- a/scripts/style_fix.sh +++ b/scripts/style_fix.sh @@ -4,10 +4,10 @@ echo "Fixing issues automatically..." -autopep8 --in-place --ignore E501 carta/*.py +autopep8 --in-place --ignore E501 carta/*.py tests/*.py # Check for PEP8 code style violations, but ignore long lines and ambiguous variable names echo "Outstanding issues:" -pycodestyle --ignore=E501,E741 carta/*.py +pycodestyle --ignore=E501,E741 carta/*.py tests/*.py diff --git a/setup.py b/setup.py index e12ff61..a007e9c 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="carta", - version="1.1.7", + version="1.1.8", author="Adrianna Pińska", author_email="adrianna.pinska@gmail.com", description="CARTA scripting wrapper written in Python", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_image.py b/tests/test_image.py new file mode 100644 index 0000000..72ba53e --- /dev/null +++ b/tests/test_image.py @@ -0,0 +1,222 @@ +import types +import pytest + +from carta.session import Session +from carta.image import Image +from carta.util import CartaValidationFailed +from carta.constants import NumberFormat as NF, CoordinateSystem, SpatialAxis as SA + +# FIXTURES + + +@pytest.fixture +def session(): + """Return a session object. + + The session's protocol is set to None, so any tests that use this must also mock the session's call_action and/or higher-level functions which call it. + """ + return Session(0, None) + + +@pytest.fixture +def image(session): + """Return an image object which uses the session fixture. + """ + return Image(session, 0, "") + + +@pytest.fixture +def mock_get_value(image, mocker): + """Return a mock for image's get_value.""" + return mocker.patch.object(image, "get_value") + + +@pytest.fixture +def mock_call_action(image, mocker): + """Return a mock for image's call_action.""" + return mocker.patch.object(image, "call_action") + + +@pytest.fixture +def mock_session_call_action(session, mocker): + """Return a mock for session's call_action.""" + return mocker.patch.object(session, "call_action") + + +@pytest.fixture +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 func + + +@pytest.fixture +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 func + + +@pytest.fixture +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 func + +# TESTS + +# DOCSTRINGS + + +def test_image_class_has_docstring(): + assert Image.__doc__ is not None + + +def find_members(*classes, member_type=types.FunctionType): + for clazz in classes: + for name in dir(clazz): + if not name.startswith('__') and isinstance(getattr(clazz, name), member_type): + yield getattr(clazz, name) + + +@pytest.mark.parametrize("member", find_members(Image)) +def test_image_methods_have_docstrings(member): + assert member.__doc__ is not None + + +@pytest.mark.parametrize("member", find_members(Image, member_type=types.MethodType)) +def test_image_classmethods_have_docstrings(member): + assert member.__doc__ is not None + + +@pytest.mark.parametrize("member", [m.fget for m in find_members(Image, member_type=property)]) +def test_image_properties_have_docstrings(member): + assert member.__doc__ is not None + +# SIMPLE PROPERTIES TODO to be completed. + + +@pytest.mark.parametrize("property_name,expected_path", [ + ("directory", "frameInfo.directory"), + ("width", "frameInfo.fileInfoExtended.width"), +]) +def test_simple_properties(image, property_name, expected_path, mock_get_value): + getattr(image, property_name) + mock_get_value.assert_called_with(expected_path) + +# TODO tests for all existing functions to be filled in + + +def test_make_active(image, mock_session_call_action): + image.make_active() + mock_session_call_action.assert_called_with("setActiveFrameById", 0) + + +@pytest.mark.parametrize("channel", [0, 10, 19]) +def test_set_channel_valid(image, channel, mock_call_action, mock_property): + mock_property("depth", 20) + + image.set_channel(channel) + mock_call_action.assert_called_with("setChannels", channel, image.macro("", "requiredStokes"), True) + + +@pytest.mark.parametrize("channel,error_contains", [ + (20, "must be smaller"), + (1.5, "not an increment of 1"), + (-3, "must be greater or equal"), +]) +def test_set_channel_invalid(image, channel, error_contains, mock_property): + mock_property("depth", 20) + + with pytest.raises(CartaValidationFailed) as e: + image.set_channel(channel) + assert error_contains in str(e.value) + + +@pytest.mark.parametrize("x", [-30, 0, 10, 12.3, 30]) +@pytest.mark.parametrize("y", [-30, 0, 10, 12.3, 30]) +def test_set_center_valid_pixels(image, mock_property, mock_call_action, x, y): + # Currently we have no range validation, for consistency with WCS coordinates. + mock_property("width", 20) + mock_property("height", 20) + + image.set_center(f"{x}px", f"{y}px") + mock_call_action.assert_called_with("setCenter", float(x), float(y)) + + +@pytest.mark.parametrize("x,y,x_fmt,y_fmt,x_norm,y_norm", [ + ("123", "12", NF.DEGREES, NF.DEGREES, "123", "12"), + (123, 12, NF.DEGREES, NF.DEGREES, "123", "12"), + ("123deg", "12 deg", NF.DEGREES, NF.DEGREES, "123", "12"), + ("12:34:56.789", "12:34:56.789", NF.HMS, NF.DMS, "12:34:56.789", "12:34:56.789"), + ("12h34m56.789s", "12d34m56.789s", NF.HMS, NF.DMS, "12:34:56.789", "12:34:56.789"), + ("12h34m56.789s", "5h34m56.789s", NF.HMS, NF.HMS, "12:34:56.789", "5:34:56.789"), + ("12d34m56.789s", "12d34m56.789s", NF.DMS, NF.DMS, "12:34:56.789", "12:34:56.789"), +]) +def test_set_center_valid_wcs(image, mock_property, mock_session_method, mock_call_action, x, y, x_fmt, y_fmt, x_norm, y_norm): + mock_property("valid_wcs", True) + mock_session_method("number_format", [(x_fmt, y_fmt, None)]) + + image.set_center(x, y) + mock_call_action.assert_called_with("setCenterWcs", x_norm, y_norm) + + +def test_set_center_valid_change_system(image, mock_property, mock_session_method, mock_call_action, mock_session_call_action): + mock_property("valid_wcs", True) + mock_session_method("number_format", [(NF.DEGREES, NF.DEGREES, None)]) + + image.set_center("123", "12", CoordinateSystem.GALACTIC) + + # We're not testing if this system has the correct format; just that the function is called + mock_session_call_action.assert_called_with("overlayStore.global.setSystem", CoordinateSystem.GALACTIC) + mock_call_action.assert_called_with("setCenterWcs", "123", "12") + + +@pytest.mark.parametrize("x,y,wcs,x_fmt,y_fmt,error_contains", [ + ("abc", "def", True, NF.DEGREES, NF.DEGREES, "Invalid function parameter"), + ("123", "123", False, NF.DEGREES, NF.DEGREES, "does not contain valid WCS information"), + ("123", "123", True, NF.HMS, NF.DMS, "does not match expected format"), + ("123", "123", True, NF.DEGREES, NF.DMS, "does not match expected format"), + ("123px", "123", True, NF.DEGREES, NF.DEGREES, "Cannot mix image and world coordinates"), + ("123", "123px", True, NF.DEGREES, NF.DEGREES, "Cannot mix image and world coordinates"), +]) +def test_set_center_invalid(image, mock_property, mock_session_method, mock_call_action, x, y, wcs, x_fmt, y_fmt, error_contains): + mock_property("width", 200) + mock_property("height", 200) + mock_property("valid_wcs", wcs) + mock_session_method("number_format", [(x_fmt, y_fmt, None)]) + + with pytest.raises(Exception) as e: + image.set_center(x, y) + assert error_contains in str(e.value) + + +@pytest.mark.parametrize("axis", [SA.X, SA.Y]) +@pytest.mark.parametrize("val,action,norm", [ + ("123px", "zoomToSize{0}", 123.0), + ("123arcsec", "zoomToSize{0}Wcs", "123\""), + ("123\"", "zoomToSize{0}Wcs", "123\""), + ("123", "zoomToSize{0}Wcs", "123\""), + ("123arcmin", "zoomToSize{0}Wcs", "123'"), + ("123deg", "zoomToSize{0}Wcs", "123deg"), + ("123 deg", "zoomToSize{0}Wcs", "123deg"), +]) +def test_zoom_to_size(image, mock_property, mock_call_action, axis, val, action, norm): + mock_property("valid_wcs", True) + image.zoom_to_size(val, axis) + mock_call_action.assert_called_with(action.format(axis.upper()), norm) + + +@pytest.mark.parametrize("axis", [SA.X, SA.Y]) +@pytest.mark.parametrize("val,wcs,error_contains", [ + ("abc", True, "Invalid function parameter"), + ("123arcsec", False, "does not contain valid WCS information"), +]) +def test_zoom_to_size_invalid(image, mock_property, axis, val, wcs, error_contains): + mock_property("valid_wcs", wcs) + with pytest.raises(Exception) as e: + image.zoom_to_size(val, axis) + assert error_contains in str(e.value) diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..e3cae8c --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,130 @@ +import types +import pytest + +from carta.session import Session +from carta.util import CartaValidationFailed +from carta.constants import CoordinateSystem, NumberFormat as NF + +# FIXTURES + + +@pytest.fixture +def session(): + """Return a session object. + + The session's protocol is set to None, so any tests that use this must also mock the session's call_action and/or higher-level functions which call it. + """ + return Session(0, None) + + +@pytest.fixture +def mock_get_value(session, mocker): + """Return a mock for session's get_value.""" + return mocker.patch.object(session, "get_value") + + +@pytest.fixture +def mock_call_action(session, mocker): + """Return a mock for session's call_action.""" + return mocker.patch.object(session, "call_action") + + +@pytest.fixture +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 func + + +@pytest.fixture +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 func + + +# TESTS + + +def test_session_class_has_docstring(): + assert Session.__doc__ is not None + + +def find_members(*classes, member_type=types.FunctionType): + for clazz in classes: + for name in dir(clazz): + if not name.startswith('__') and isinstance(getattr(clazz, name), member_type): + yield getattr(clazz, name) + + +@pytest.mark.parametrize("member", find_members(Session)) +def test_session_methods_have_docstrings(member): + assert member.__doc__ is not None + + +@pytest.mark.parametrize("member", find_members(Session, member_type=types.MethodType)) +def test_session_classmethods_have_docstrings(member): + assert member.__doc__ is not None + + +# TODO fill in missing session tests + + +@pytest.mark.parametrize("system", CoordinateSystem) +def test_set_coordinate_system(session, mock_call_action, system): + session.set_coordinate_system(system) + mock_call_action.assert_called_with("overlayStore.global.setSystem", system) + + +def test_set_coordinate_system_invalid(session): + with pytest.raises(CartaValidationFailed) as e: + session.set_coordinate_system("invalid") + assert "Invalid function parameter" in str(e.value) + + +def test_coordinate_system(session, mock_get_value): + mock_get_value.return_value = "AUTO" + system = session.coordinate_system() + mock_get_value.assert_called_with("overlayStore.global.system") + assert isinstance(system, CoordinateSystem) + + +@pytest.mark.parametrize("x", NF) +@pytest.mark.parametrize("y", NF) +def test_set_custom_number_format(mocker, session, mock_call_action, x, y): + session.set_custom_number_format(x, y) + mock_call_action.assert_has_calls([ + mocker.call("overlayStore.numbers.setFormatX", x), + mocker.call("overlayStore.numbers.setFormatY", y), + mocker.call("overlayStore.numbers.setCustomFormat", True), + ]) + + +@pytest.mark.parametrize("x,y", [ + ("invalid", "invalid"), + (NF.DEGREES, "invalid"), + ("invalid", NF.DEGREES), +]) +def test_set_custom_number_format_invalid(session, x, y): + with pytest.raises(CartaValidationFailed) as e: + session.set_custom_number_format(x, y) + assert "Invalid function parameter" in str(e.value) + + +def test_clear_custom_number_format(session, mock_call_action): + session.clear_custom_number_format() + mock_call_action.assert_called_with("overlayStore.numbers.setCustomFormat", False) + + +def test_number_format(session, mock_get_value, mocker): + mock_get_value.side_effect = [NF.DEGREES, NF.DEGREES, False] + x, y, _ = session.number_format() + mock_get_value.assert_has_calls([ + mocker.call("overlayStore.numbers.formatTypeX"), + mocker.call("overlayStore.numbers.formatTypeY"), + mocker.call("overlayStore.numbers.customFormat"), + ]) + assert isinstance(x, NF) + assert isinstance(y, NF) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..bb19125 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,490 @@ +import types +import pytest + +from carta.util import PixelValue, AngularSize, DegreesSize, ArcminSize, ArcsecSize, MilliarcsecSize, MicroarcsecSize, WorldCoordinate, DegreesCoordinate, HMSCoordinate, DMSCoordinate +from carta.constants import NumberFormat as NF, SpatialAxis as SA + + +@pytest.mark.parametrize("clazz", [PixelValue, AngularSize, WorldCoordinate]) +def test_class_has_docstring(clazz): + assert clazz.__doc__ is not None + + +def find_members(*classes, member_type=types.MethodType): + for clazz in classes: + for name in dir(clazz): + if not name.startswith('__') and isinstance(getattr(clazz, name), member_type): + yield getattr(clazz, name) + + +@pytest.mark.parametrize("member", find_members(PixelValue, AngularSize, WorldCoordinate)) +def test_class_classmethods_have_docstrings(member): + assert member.__doc__ is not None + + +@pytest.mark.parametrize("value,valid", [ + ("123px", True), + ("123.4px", True), + ("123pix", True), + ("123pixel", True), + ("123pixels", True), + ("123 px", True), + ("123 pix", True), + ("123 pixel", True), + ("123 pixels", True), + ("-123px", True), + ("-123.4px", True), + + ("123arcmin", False), + ("123deg", False), + ("abc", False), + ("123", False), + ("123abc", False), +]) +def test_pixel_value_valid(value, valid): + assert PixelValue.valid(value) == valid + + +@pytest.mark.parametrize("value,num", [ + ("123px", 123), + ("123pix", 123), + ("123pixel", 123), + ("123pixels", 123), + ("123 px", 123), + ("123 pix", 123), + ("123 pixel", 123), + ("123 pixels", 123), + ("123.45px", 123.45), + ("123.45 px", 123.45), + ("-123.45px", -123.45), + ("-123.45 px", -123.45), +]) +def test_pixel_value_as_float(value, num): + assert PixelValue.as_float(value) == num + + +@pytest.mark.parametrize("value", ["123arcmin", "123deg", "abc", "123", "123abc"]) +def test_pixel_value_as_float_invalid(value): + with pytest.raises(ValueError) as e: + PixelValue.as_float(value) + assert "not in a recognized pixel format" in str(e.value) + + +@pytest.mark.parametrize("size,valid", [ + ("123deg", True), + ("123degree", True), + ("123degrees", True), + ("123 deg", True), + ("123 degree", True), + ("123 degrees", True), + + ("123 arcmin", False), + ("123cm", False), + ("abc", False), + ("-123", False), + ("123px", False), +]) +def test_degrees_size_valid(size, valid): + assert DegreesSize.valid(size) == valid + if valid: + assert AngularSize.valid(size) == valid + + +@pytest.mark.parametrize("size,valid", [ + ("123arcminutes", True), + ("123arcminute", True), + ("123arcmin", True), + ("123amin", True), + ("123 arcminutes", True), + ("123 arcminute", True), + ("123 arcmin", True), + ("123 amin", True), + ("123'", True), + ("123′", True), + + ("123 degrees", False), + ("123cm", False), + ("abc", False), + ("-123", False), + ("123px", False), +]) +def test_arcmin_size_valid(size, valid): + assert ArcminSize.valid(size) == valid + if valid: + assert AngularSize.valid(size) == valid + + +@pytest.mark.parametrize("size,valid", [ + ("123arcseconds", True), + ("123arcsecond", True), + ("123arcsec", True), + ("123asec", True), + ("123 arcseconds", True), + ("123 arcsecond", True), + ("123 arcsec", True), + ("123 asec", True), + ("123", True), + ("123\"", True), + ("123″", True), + + ("123 degrees", False), + ("123cm", False), + ("abc", False), + ("-123", False), + ("123px", False), +]) +def test_arcsec_size_valid(size, valid): + assert ArcsecSize.valid(size) == valid + if valid: + assert AngularSize.valid(size) == valid + + +@pytest.mark.parametrize("size,valid", [ + ("123milliarcseconds", True), + ("123milliarcsecond", True), + ("123milliarcsec", True), + ("123mas", True), + ("123 milliarcseconds", True), + ("123 milliarcsecond", True), + ("123 milliarcsec", True), + ("123 mas", True), + + ("123 degrees", False), + ("123cm", False), + ("abc", False), + ("-123", False), + ("123px", False), +]) +def test_milliarcsec_size_valid(size, valid): + assert MilliarcsecSize.valid(size) == valid + if valid: + assert AngularSize.valid(size) == valid + + +@pytest.mark.parametrize("size,valid", [ + ("123microarcseconds", True), + ("123microarcsecond", True), + ("123microarcsec", True), + ("123µas", True), + ("123uas", True), + ("123 microarcseconds", True), + ("123 microarcsecond", True), + ("123 microarcsec", True), + ("123 µas", True), + ("123 uas", True), + + ("123 degrees", False), + ("123cm", False), + ("abc", False), + ("-123", False), + ("123px", False), +]) +def test_microarcsec_size_valid(size, valid): + assert MicroarcsecSize.valid(size) == valid + if valid: + assert AngularSize.valid(size) == valid + + +@pytest.mark.parametrize("size,norm", [ + ("123arcminutes", "123'"), + ("123arcminute", "123'"), + ("123arcmin", "123'"), + ("123amin", "123'"), + ("123 arcminutes", "123'"), + ("123 arcminute", "123'"), + ("123 arcmin", "123'"), + ("123 amin", "123'"), + ("123'", "123'"), + ("123′", "123'"), +]) +def test_arcmin_size_from_string(size, norm): + assert str(ArcminSize.from_string(size)) == norm + assert str(AngularSize.from_string(size)) == norm + + +@pytest.mark.parametrize("size,norm", [ + ("123arcseconds", "123\""), + ("123arcsecond", "123\""), + ("123arcsec", "123\""), + ("123asec", "123\""), + ("123 arcseconds", "123\""), + ("123 arcsecond", "123\""), + ("123 arcsec", "123\""), + ("123 asec", "123\""), + ("123", "123\""), + ("123\"", "123\""), + ("123″", "123\""), +]) +def test_arcsec_size_from_string(size, norm): + assert str(ArcsecSize.from_string(size)) == norm + assert str(AngularSize.from_string(size)) == norm + + +@pytest.mark.parametrize("size,norm", [ + ("123deg", "123deg"), + ("123degree", "123deg"), + ("123degrees", "123deg"), + ("123 deg", "123deg"), + ("123 degree", "123deg"), + ("123 degrees", "123deg"), +]) +def test_degrees_size_from_string(size, norm): + assert str(DegreesSize.from_string(size)) == norm + assert str(AngularSize.from_string(size)) == norm + + +@pytest.mark.parametrize("size,norm", [ + ("123milliarcseconds", "0.123\""), + ("123milliarcsecond", "0.123\""), + ("123milliarcsec", "0.123\""), + ("123mas", "0.123\""), + ("123 milliarcseconds", "0.123\""), + ("123 milliarcsecond", "0.123\""), + ("123 milliarcsec", "0.123\""), + ("123 mas", "0.123\""), +]) +def test_milliarcsec_size_from_string(size, norm): + assert str(MilliarcsecSize.from_string(size)) == norm + assert str(AngularSize.from_string(size)) == norm + + +@pytest.mark.parametrize("size,norm", [ + ("123microarcseconds", "0.000123\""), + ("123microarcsecond", "0.000123\""), + ("123microarcsec", "0.000123\""), + ("123µas", "0.000123\""), + ("123uas", "0.000123\""), + ("123 microarcseconds", "0.000123\""), + ("123 microarcsecond", "0.000123\""), + ("123 microarcsec", "0.000123\""), + ("123 µas", "0.000123\""), + ("123 uas", "0.000123\""), +]) +def test_microarcsec_size_from_string(size, norm): + assert str(MicroarcsecSize.from_string(size)) == norm + assert str(AngularSize.from_string(size)) == norm + + +@pytest.mark.parametrize("clazz,size", [ + (DegreesSize, "123arcsec"), + (ArcminSize, "123degrees"), + (ArcsecSize, "123degrees"), + (MilliarcsecSize, "123degrees"), + (MicroarcsecSize, "123degrees"), +]) +def test_angular_size_from_string_one_invalid(clazz, size): + with pytest.raises(ValueError) as e: + clazz.from_string(size) + assert "not in a recognized" in str(e.value) + + +@pytest.mark.parametrize("size", ["123cm", "abc", "-123", "123px"]) +def test_angular_size_from_string_all_invalid(size): + with pytest.raises(ValueError) as e: + AngularSize.from_string(size) + assert "not in a recognized angular size format" in str(e.value) + + +@pytest.mark.parametrize("coord,valid", [ + ("0deg", True), + ("123 degrees", True), + ("123degrees", True), + ("123 degree", True), + ("123degree", True), + ("123 deg", True), + ("123deg", True), + ("123", True), + ("1deg", True), + + ("12:34:56.789", False), + ("123abc", False), + ("12d34m56.789s", False), + ("12h34m56.789s", False), + ("abc", False), +]) +def test_degrees_coordinate_valid(coord, valid): + assert DegreesCoordinate.valid(coord) == valid + + +@pytest.mark.parametrize("coord,valid", [ + ("00:00:00.0", True), + ("00:00:00", True), + ("0:00:00", True), + ("-12:34:56.789", True), + ("12:34:56.789", True), + ("12:34:56", True), + ("12h04m05s", True), + ("-12h34m56.789s", True), + ("12h34m56.789s", True), + ("12h34m56s", True), + ("1:2:3", True), + ("12h34m", True), + ("12m34s", True), + ("12h34s", True), + ("12h", True), + ("12m", True), + ("12s", True), + ("::", True), + ("", True), + + ("100:00:00", False), + ("10:00:60", False), + ("10:00:65", False), + ("10:60:00", False), + ("10:65:00", False), + ("12:34:56,7", False), + ("12:34:567", False), + ("12:345:67", False), + ("12:34", False), + ("123abc", False), + ("12d34m56.789s", False), + ("24:00:00", False), + ("30:00:00", False), + ("abc", False), +]) +def test_hms_coordinate_valid(coord, valid): + assert HMSCoordinate.valid(coord) == valid + + +@pytest.mark.parametrize("coord,valid", [ + ("00:00:00.0", True), + ("00:00:00", True), + ("0:00:00", True), + ("100:00:00", True), + ("-12:34:56.789", True), + ("12:34:56.789", True), + ("12:34:56", True), + ("12d04m05s", True), + ("-12d34m56.789s", True), + ("12d34m56.789s", True), + ("12d34m56s", True), + ("360:00:00", True), + ("400:00:00", True), + ("1:2:3", True), + ("12d34m", True), + ("12m34s", True), + ("12d34s", True), + ("12d", True), + ("12m", True), + ("12s", True), + ("::", True), + ("", True), + + ("10:00:60", False), + ("10:00:65", False), + ("10:60:00", False), + ("10:65:00", False), + ("12:34:56,7", False), + ("12:34:567", False), + ("12:345:67", False), + ("12:34", False), + ("123abc", False), + ("12h34m56.789s", False), + ("abc", False), +]) +def test_dms_coordinate_valid(coord, valid): + assert DMSCoordinate.valid(coord) == valid + + +def test_world_coordinate_valid(mocker): + degrees_valid = mocker.patch.object(DegreesCoordinate, "valid", return_value=True) + hms_valid = mocker.patch.object(HMSCoordinate, "valid", return_value=True) + dms_valid = mocker.patch.object(DMSCoordinate, "valid", return_value=True) + + assert WorldCoordinate.valid("example") # Valid because first child returned true + + degrees_valid.assert_called_with("example") # First child should have been called + assert not hms_valid.called # Subsequent children not called because any short-circuits + assert not dms_valid.called + + +def test_world_coordinate_invalid(mocker): + degrees_valid = mocker.patch.object(DegreesCoordinate, "valid", return_value=False) + hms_valid = mocker.patch.object(HMSCoordinate, "valid", return_value=False) + dms_valid = mocker.patch.object(DMSCoordinate, "valid", return_value=False) + + assert not WorldCoordinate.valid("example") # Invalid because all children returned false + + degrees_valid.assert_called_with("example") # All children should have been called + hms_valid.assert_called_with("example") + dms_valid.assert_called_with("example") + + +@pytest.mark.parametrize("coord,axis,norm,error", [ + ("123", SA.X, "123", None), + ("400", SA.X, None, "outside the permitted longitude range"), + ("-123", SA.X, None, "outside the permitted longitude range"), + ("12", SA.Y, "12", None), + ("-34.5", SA.Y, "-34.5", None), + ("123", SA.Y, None, "outside the permitted latitude range"), + ("-123", SA.Y, None, "outside the permitted latitude range"), +]) +def test_degrees_coordinate_from_string(coord, axis, norm, error): + if norm is not None: + assert str(DegreesCoordinate.from_string(coord, axis)) == norm + else: + with pytest.raises(ValueError) as e: + DegreesCoordinate.from_string(coord, axis) + assert error in str(e.value) + + +@pytest.mark.parametrize("coord,axis,norm,error", [ + ("12:34:56.7", SA.X, "12:34:56.7", None), + ("-12:34:56.7", SA.X, None, "outside the permitted longitude range"), + ("5:34:56.7", SA.Y, "5:34:56.7", None), + ("-5:34:56.7", SA.Y, "-5:34:56.7", None), + ("12:34:56.7", SA.Y, None, "outside the permitted latitude range"), + ("-12:34:56.7", SA.Y, None, "outside the permitted latitude range"), + ("1:2:3", SA.X, "1:02:03", None), + ("12h34m", SA.X, "12:34:00", None), + ("12m34s", SA.X, "0:12:34", None), + ("12h34s", SA.X, "12:00:34", None), + ("12h", SA.X, "12:00:00", None), + ("12m", SA.X, "0:12:00", None), + ("12s", SA.X, "0:00:12", None), + ("::", SA.X, "0:00:00", None), + ("", SA.X, "0:00:00", None), +]) +def test_hms_coordinate_from_string(coord, axis, norm, error): + if norm is not None: + assert str(HMSCoordinate.from_string(coord, axis)) == norm + else: + with pytest.raises(ValueError) as e: + HMSCoordinate.from_string(coord, axis) + assert error in str(e.value) + + +@pytest.mark.parametrize("coord,axis,norm,error", [ + ("12:34:56.7", SA.X, "12:34:56.7", None), + ("400:34:56.7", SA.X, None, "outside the permitted longitude range"), + ("-12:34:56.7", SA.X, None, "outside the permitted longitude range"), + ("12:34:56.7", SA.Y, "12:34:56.7", None), + ("-12:34:56.7", SA.Y, "-12:34:56.7", None), + ("100:34:56.7", SA.Y, None, "outside the permitted latitude range"), + ("-100:34:56.7", SA.Y, None, "outside the permitted latitude range"), + ("1:2:3", SA.X, "1:02:03", None), + ("12d34m", SA.X, "12:34:00", None), + ("12m34s", SA.X, "0:12:34", None), + ("12d34s", SA.X, "12:00:34", None), + ("12d", SA.X, "12:00:00", None), + ("12m", SA.X, "0:12:00", None), + ("12s", SA.X, "0:00:12", None), + ("::", SA.X, "0:00:00", None), + ("", SA.X, "0:00:00", None), +]) +def test_dms_coordinate_from_string(coord, axis, norm, error): + if norm is not None: + assert str(DMSCoordinate.from_string(coord, axis)) == norm + else: + with pytest.raises(ValueError) as e: + DMSCoordinate.from_string(coord, axis) + assert error in str(e.value) + + +@pytest.mark.parametrize("fmt,expected_child", [ + (NF.DEGREES, DegreesCoordinate), + (NF.HMS, HMSCoordinate), + (NF.DMS, DMSCoordinate), +]) +def test_world_coordinate_with_format(fmt, expected_child): + assert WorldCoordinate.with_format(fmt) == expected_child diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..7b3e1e0 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,31 @@ +import pytest + +from carta.validation import Size, Coordinate + + +@pytest.mark.parametrize('val', [123, "123arcmin", "123arcsec", "123deg", "123degree", "123degrees", "123px", "123pix", "123pixel", "123pixels", "123 arcmin", "123 arcsec", "123 deg", "123 degree", "123 degrees", "123 px", "123 pix", "123 pixel", "123 pixels", "123", "123\"", "123'"]) +def test_size_valid(val): + v = Size() + v.validate(val, None) + + +@pytest.mark.parametrize('val', ["123abc", "abc", "123 \"", "123 '", ""]) +def test_size_invalid(val): + v = Size() + with pytest.raises(ValueError) as e: + v.validate(val, None) + assert "not a number or a numeric string with valid size units" in str(e.value) + + +@pytest.mark.parametrize('val', [123, 123.4, "123", "123.4", "12:34:56", "12:34:56.7", "01:02:03", "1:02:03", "0:01:02", "00:12:34", "00:00:00", "12:34:5", "12:34:5.678", "12h34m56.789s", "1:2:3", ":1:2", ":12:34", "::1", "::", "1::", ":2:", "12h34m", "10h", "10d", "100d", "10m", "10s", "1.2s", "1m2s", "1h2s", "", "123 deg", "123 degree", "123 degrees"]) +def test_coordinate_valid(val): + v = Coordinate() + v.validate(val, None) + + +@pytest.mark.parametrize('val', ["123abc", "abc", "12:345:67", "12:34:567", "12:34", "123:45:67", "hms", "hm", "ms", "h", "m", "s", "hs", "12hms", "12h34ms", "h12m34s", "100h", "12:34:56,7"]) +def test_coordinate_invalid(val): + v = Coordinate() + with pytest.raises(ValueError) as e: + v.validate(val, None) + assert "not a number, a string in H:M:S or D:M:S format, or a numeric string with degree units or pixel units" in str(e.value)