diff --git a/carta/_enum_snapshots.py b/carta/_enum_snapshots.py new file mode 100644 index 0000000..436e89e --- /dev/null +++ b/carta/_enum_snapshots.py @@ -0,0 +1,53 @@ +"""Validate carta.constants enums against frontend enum snapshots.""" + +from . import constants + + +def build_constant_enum_dict(): + """Build a dictionary of enums registered for external validation.""" + enum_dict = {} + for registry in (constants.FrontendEnum, constants.ProtobufEnum): + for snapshot_name, enum_ in registry.ENUMS.items(): + enum_dict[enum_.__name__] = { + "snapshot_name": snapshot_name, + "enum": enum_, + } + return enum_dict + + +def find_enum_snapshot_mismatches(enum_dict, enum_snapshots): + """Compare frontend enum snapshots with local constants enums.""" + mismatches = [] + + for (enum_name, enum_value), snapshot in zip(enum_dict.items(), enum_snapshots): + frontend_dict = {i["name"].upper().replace("_", ""): [i["name"], i["value"]] for i in snapshot} + python_dict = {i.name.replace("_", ""): [i.name, i.value] for i in enum_value["enum"]} + missing_in_python = [ + {"frontend_name": frontend_dict[name][0], "frontend_value": frontend_dict[name][1]} + for name in sorted(frontend_dict.keys() - python_dict.keys()) + ] + missing_in_frontend = [ + {"python_name": python_dict[name][0], "python_value": python_dict[name][1]} + for name in sorted(python_dict.keys() - frontend_dict.keys()) + ] + value_mismatches = [ + { + "python_name": python_dict[name][0], + "frontend_name": frontend_dict[name][0], + "python_value": python_dict[name][1], + "frontend_value": frontend_dict[name][1], + } + for name in sorted(python_dict.keys() & frontend_dict.keys()) + if python_dict[name][1] != frontend_dict[name][1] + ] + if missing_in_python or missing_in_frontend or value_mismatches: + mismatches.append( + { + "name": enum_name, + "source": enum_value["snapshot_name"], + "missing_in_python": missing_in_python, + "missing_in_frontend": missing_in_frontend, + "value_mismatches": value_mismatches, + } + ) + return mismatches diff --git a/carta/constants.py b/carta/constants.py index 87dc673..08c9f4f 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -11,6 +11,48 @@ class StrEnum(str, Enum): pass +class _RegisteredEnum: + """Mixin implementation shared by externally defined enum registries.""" + + ENUMS = {} + + def __init_subclass__(cls, *, snapshot_name=None, **kwargs): + """Register an enum subclass by its external snapshot name.""" + super().__init_subclass__(**kwargs) + if _RegisteredEnum in cls.__bases__: + return + snapshot_name = snapshot_name or cls.__name__ + cls.SNAPSHOT_NAME = snapshot_name + cls.ENUMS[snapshot_name] = cls + + +class FrontendEnum(_RegisteredEnum): + """Mixin for enums defined and validated by the CARTA frontend.""" + + ENUMS = {} + + +class ProtobufEnum(_RegisteredEnum): + """Mixin for enums defined and validated by CARTA protobuf messages.""" + + ENUMS = {} + + +def _registered_enum(registry, enum_type, enum_name, names, snapshot_name=None, **kwargs): + """Create a functional enum and register it with the requested mixin.""" + enum_ = enum_type(enum_name, names, type=registry, **kwargs) + if snapshot_name is not None: + _set_snapshot_name(enum_, registry, snapshot_name) + return enum_ + + +def _set_snapshot_name(enum_, registry, snapshot_name): + """Set a custom name after EnumMeta has constructed an enum class.""" + del registry.ENUMS[enum_.__name__] + enum_.SNAPSHOT_NAME = snapshot_name + registry.ENUMS[snapshot_name] = enum_ + + class ComplexComponent(StrEnum): """Complex component.""" AMPLITUDE = "AMPLITUDE" @@ -19,33 +61,31 @@ class ComplexComponent(StrEnum): IMAG = "IMAG" -Colormap = StrEnum('Colormap', {c.upper(): c for c in ('copper', 'paired', 'gist_heat', 'brg', 'cool', 'summer', 'OrRd', 'tab20c', 'purples', 'gray', 'terrain', 'RdPu', 'set2', 'spring', 'gist_yarg', 'RdYlBu', 'reds', 'winter', 'Wistia', 'rainbow', 'dark2', 'oranges', 'BuPu', 'gist_earth', 'PuBu', 'pink', 'PuOr', 'pastel2', 'PiYG', 'gist_ncar', 'PuRd', 'plasma', 'gist_stern', 'hot', 'PuBuGn', 'YlOrRd', 'accent', 'magma', 'set1', 'GnBu', 'greens', 'CMRmap', 'gist_rainbow', 'prism', 'hsv', 'Blues', 'viridis', 'YlGn', 'spectral', 'RdBu', 'tab20', 'greys', 'flag', 'jet', 'seismic', 'PRGn', 'coolwarm', 'YlOrBr', 'RdYlGn', 'bone', 'autumn', 'BrBG', 'gnuplot2', 'RdGy', 'binary', 'gnuplot', 'BuGn', 'gist_gray', 'nipy_spectral', 'set3', 'tab20b', 'pastel1', 'afmhot', 'cubehelix', 'YlGnBu', 'ocean', 'tab10', 'bwr', 'inferno')}) +Colormap = _registered_enum(FrontendEnum, StrEnum, 'Colormap', {c.upper(): c for c in ('copper', 'paired', 'gist_heat', 'brg', 'cool', 'summer', 'OrRd', 'tab20c', 'purples', 'gray', 'terrain', 'RdPu', 'set2', 'spring', 'gist_yarg', 'RdYlBu', 'reds', 'winter', 'Wistia', 'rainbow', 'dark2', 'oranges', 'BuPu', 'gist_earth', 'PuBu', 'pink', 'PuOr', 'pastel2', 'PiYG', 'gist_ncar', 'PuRd', 'plasma', 'gist_stern', 'hot', 'PuBuGn', 'YlOrRd', 'accent', 'magma', 'set1', 'GnBu', 'greens', 'CMRmap', 'gist_rainbow', 'prism', 'hsv', 'Blues', 'viridis', 'YlGn', 'spectral', 'RdBu', 'tab20', 'greys', 'flag', 'jet', 'seismic', 'PRGn', 'coolwarm', 'YlOrBr', 'RdYlGn', 'bone', 'autumn', 'BrBG', 'gnuplot2', 'RdGy', 'binary', 'gnuplot', 'BuGn', 'gist_gray', 'nipy_spectral', 'set3', 'tab20b', 'pastel1', 'afmhot', 'cubehelix', 'YlGnBu', 'ocean', 'tab10', 'bwr', 'inferno', 'Blue', 'Cyan', 'Green', 'Magenta', 'Orange', 'Red', 'Violet', 'Yellow')}, snapshot_name="ColorMap") Colormap.__doc__ = """All available colormaps.""" -class ColormapSet(StrEnum): +class ColormapSet(FrontendEnum, StrEnum): """Colormap sets for color blending.""" RGB = "RGB" CMY = "CMY" RAINBOW = "Rainbow" -class ImageType(IntEnum): - """View item types, corresponding to the frontend ImageType enum.""" +class ImageType(FrontendEnum, IntEnum): + """Image view item types, corresponding to the frontend ImageType enum.""" FRAME = 0 COLOR_BLENDING = 1 PV_PREVIEW = 2 -Scaling = IntEnum('Scaling', ('LINEAR', 'LOG', 'SQRT', 'SQUARE', 'POWER', 'GAMMA'), start=0) +Scaling = _registered_enum(FrontendEnum, IntEnum, 'Scaling', ('LINEAR', 'LOG', 'SQRT', 'SQUARE', 'POWER', 'GAMMA', 'EXP', 'CUSTOM', 'SINH', 'ASINH'), snapshot_name="FrameScaling", start=0) Scaling.__doc__ = """Colormap scaling types.""" - - -CoordinateSystem = StrEnum('CoordinateSystem', {c: c for c in ("AUTO", "ECLIPTIC", "FK4", "FK5", "GALACTIC", "ICRS")}) +CoordinateSystem = _registered_enum(FrontendEnum, StrEnum, 'CoordinateSystem', {c: c for c in ("AUTO", "ECLIPTIC", "FK4", "FK5", "GALACTIC", "ICRS")} | {"IMAGE": "CARTESIAN"}, snapshot_name="SystemType") CoordinateSystem.__doc__ = """Coordinate systems.""" -class NumberFormat(StrEnum): +class NumberFormat(FrontendEnum, StrEnum, snapshot_name="NumberFormatType"): """Number formats.""" DEGREES = "d" HMS = "hms" @@ -58,13 +98,13 @@ class SpatialAxis(StrEnum): Y = "y" -class LabelType(StrEnum): +class LabelType(FrontendEnum, StrEnum): """Label types.""" INTERIOR = "Interior" EXTERIOR = "Exterior" -class BeamType(StrEnum): +class BeamType(FrontendEnum, StrEnum): """Beam types.""" OPEN = "open" SOLID = "solid" @@ -147,16 +187,14 @@ def __init__(self, value): Member values are paths to stores corresponding to these elements, relative to the WCS overlay store. """ - - -class SmoothingMode(IntEnum): +class SmoothingMode(ProtobufEnum, IntEnum, snapshot_name="protobuf:SmoothingMode"): """Contour smoothing modes.""" NO_SMOOTHING = 0 BLOCK_AVERAGE = 1 GAUSSIAN_BLUR = 2 -VectorOverlaySource = Enum('VectorOverlaySource', ('NONE', 'CURRENT', 'COMPUTED'), type=int, start=-1) +VectorOverlaySource = _registered_enum(FrontendEnum, Enum, 'VectorOverlaySource', ('NONE', 'CURRENT', 'COMPUTED'), start=-1) VectorOverlaySource.__doc__ = """Vector overlay source.""" @@ -165,11 +203,11 @@ class Auto(StrEnum): AUTO = "Auto" -class ContourDashMode(StrEnum): +class ContourDashMode(FrontendEnum, StrEnum): """Contour dash modes.""" NONE = "None" DASHED = "Dashed" - NEGATIVE_ONLY = "NegativeOnly" + NEGATIVE_ONLY = "Negative only" PROTO_POLARIZATION = { @@ -193,9 +231,8 @@ class ContourDashMode(StrEnum): } -class Polarization(IntEnum): - """Polarizations, corresponding to the POLARIZATIONS enum in the frontend.""" - +class Polarization(FrontendEnum, IntEnum, snapshot_name="Polarizations"): + """Polarizations.""" def __init__(self, value): self.proto_index = PROTO_POLARIZATION[self.name] @@ -224,13 +261,14 @@ class PanelMode(IntEnum): MULTIPLE = 1 -class GridMode(StrEnum): +class GridMode(FrontendEnum, StrEnum, snapshot_name="ImagePanelMode"): """Grid modes.""" DYNAMIC = "dynamic" FIXED = "fixed" + NONE = "none" -class FileType(IntEnum): +class FileType(ProtobufEnum, IntEnum, snapshot_name="protobuf:FileType"): """File types corresponding to the protobuf enum.""" CASA = 0 CRTF = 1 @@ -241,9 +279,8 @@ class FileType(IntEnum): UNKNOWN = 6 -class RegionType(IntEnum): +class RegionType(ProtobufEnum, IntEnum, snapshot_name="protobuf:RegionType"): """Region types corresponding to the protobuf enum.""" - def __init__(self, value): self.is_annotation = self.name.startswith("ANN") self.label = f"{self.name[3:].title()} - Ann" if self.is_annotation else self.name.title() @@ -267,13 +304,13 @@ def __init__(self, value): ANNCOMPASS = 16 -class CoordinateType(IntEnum): +class CoordinateType(ProtobufEnum, IntEnum, snapshot_name="protobuf:CoordinateType"): """Coordinate types corresponding to the protobuf enum.""" PIXEL = 0 WORLD = 1 -class PointShape(IntEnum): +class PointShape(ProtobufEnum, IntEnum, snapshot_name="protobuf:PointAnnotationShape"): """Point annotation shapes corresponding to the protobuf enum.""" SQUARE = 0 BOX = 1 @@ -285,7 +322,7 @@ class PointShape(IntEnum): X = 7 -class TextPosition(IntEnum): +class TextPosition(ProtobufEnum, IntEnum, snapshot_name="protobuf:TextAnnotationPosition"): """Text annotation positions corresponding to the protobuf enum.""" CENTER = 0 UPPER_LEFT = 1 @@ -298,7 +335,7 @@ class TextPosition(IntEnum): RIGHT = 8 -class AnnotationFontStyle(StrEnum): +class AnnotationFontStyle(FrontendEnum, StrEnum, snapshot_name="FontStyle"): """Font styles which may be used in annotations.""" NORMAL = "Normal" BOLD = "Bold" @@ -306,7 +343,7 @@ class AnnotationFontStyle(StrEnum): BOLD_ITALIC = "Italic Bold" -class AnnotationFont(StrEnum): +class AnnotationFont(FrontendEnum, StrEnum, snapshot_name="Font"): """Fonts which may be used in annotations.""" HELVETICA = "Helvetica" TIMES = "Times" @@ -337,7 +374,7 @@ class ColorbarPosition(StrEnum): BOTTOM = "bottom" -class SpectralSystem(StrEnum): +class SpectralSystem(FrontendEnum, StrEnum): """Spectral systems.""" LSRK = "LSRK" LSRD = "LSRD" @@ -345,7 +382,7 @@ class SpectralSystem(StrEnum): TOPO = "TOPOCENT" -class SpectralUnit(StrEnum): +class SpectralUnit(FrontendEnum, StrEnum): """Spectral units.""" KMS = "km/s" MS = "m/s" @@ -358,9 +395,16 @@ class SpectralUnit(StrEnum): UM = "um" NM = "nm" ANGSTROM = "Angstrom" + M_SQUARE = "m^2" + MM_SQUARE = "mm^2" + UM_SQUARE = "um^2" + NM_SQUARE = "nm^2" + ANGSTROM_SQUARE = "Angstrom^2" SPECTRAL_TYPE_DESCRIPTION = { + "CHANNEL": "Channel", + "NATIVE": "Native", "VRAD": "Radio velocity", "VOPT": "Optical velocity", "FREQ": "Frequency", @@ -370,6 +414,8 @@ class SpectralUnit(StrEnum): SPECTRAL_TYPE_UNITS = { + "CHANNEL": tuple(), + "NATIVE": tuple(), "VRAD": (SpectralUnit.KMS, SpectralUnit.MS), "VOPT": (SpectralUnit.KMS, SpectralUnit.MS), "FREQ": (SpectralUnit.GHZ, SpectralUnit.MHZ, SpectralUnit.KHZ, SpectralUnit.HZ), @@ -378,7 +424,7 @@ class SpectralUnit(StrEnum): } -class SpectralType(StrEnum): +class SpectralType(FrontendEnum, StrEnum): """Spectral types. Members of this enum class have additional attributes. @@ -394,10 +440,13 @@ class SpectralType(StrEnum): """ def __init__(self, value): + units = SPECTRAL_TYPE_UNITS[self.name] self.description = SPECTRAL_TYPE_DESCRIPTION[self.name] - self.units = set(SPECTRAL_TYPE_UNITS[self.name]) - self.default_unit = SPECTRAL_TYPE_UNITS[self.name][0] + self.units = set(units) + self.default_unit = units[0] if units else None + CHANNEL = "CHANNEL", + NATIVE = "NATIVE", VRAD = "VRAD", VOPT = "VOPT", FREQ = "FREQ", diff --git a/carta/session.py b/carta/session.py index 8b5f143..7637794 100644 --- a/carta/session.py +++ b/carta/session.py @@ -9,6 +9,7 @@ import base64 import posixpath +from ._enum_snapshots import build_constant_enum_dict, find_enum_snapshot_mismatches from .image import Image from .view import View from .color_blending import ColorBlending @@ -258,6 +259,13 @@ def carta_version(self): """ return self.get_value("frontendVersion") + def _enum_snapshot_mismatches(self): + """Return mismatches between local constants enums and frontend snapshots.""" + enum_dict = build_constant_enum_dict() + requested_names = [info["snapshot_name"] for info in enum_dict.values()] + snapshots = self.call_action("getEnumSnapshots", requested_names, response_expected=True) + return find_enum_snapshot_mismatches(enum_dict, snapshots) + def call_action(self, path, *args, **kwargs): """Call an action on the frontend through the backend's scripting interface. diff --git a/pyproject.toml b/pyproject.toml index 6828222..469da39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,11 @@ version = { file = ["VERSION.txt"] } [tool.uv] default-groups = ["dev"] +[tool.pytest.ini_options] +markers = [ + "live_enum_snapshots: requires a running CARTA frontend/backend scripting session", +] + [tool.ruff] target-version = "py310" extend-exclude = ["docs/source/conf.py", "scripts/update_palette_colours.py"] @@ -79,3 +84,4 @@ ignore = ["E501", "E741"] [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["D"] +"tests/**/*.py" = ["D"] diff --git a/tests/live/test_live_enum_snapshots.py b/tests/live/test_live_enum_snapshots.py new file mode 100644 index 0000000..348d2ca --- /dev/null +++ b/tests/live/test_live_enum_snapshots.py @@ -0,0 +1,29 @@ +import os + +import pytest + +from carta.session import Session +from carta.token import BackendToken + + +pytestmark = [ + pytest.mark.live_enum_snapshots, + pytest.mark.skipif( + os.environ.get("CARTA_RUN_LIVE_ENUM_SNAPSHOTS") != "1", + reason="Set CARTA_RUN_LIVE_ENUM_SNAPSHOTS=1 to run live enum snapshot tests.", + ), +] + + +def test_live_enum_snapshots_match_frontend(): + frontend_url = os.environ["CARTA_FRONTEND_URL"] + session_id = os.environ["CARTA_SESSION_ID"] + if BackendToken.split_token_from_url(frontend_url)[1] is None: + debug_no_auth = True + else: + debug_no_auth = False + session = Session.interact(frontend_url, session_id, debug_no_auth=debug_no_auth) + + result = session._enum_snapshot_mismatches() + + assert result == [], result diff --git a/tests/test_enum_snapshots.py b/tests/test_enum_snapshots.py new file mode 100644 index 0000000..e7b99e4 --- /dev/null +++ b/tests/test_enum_snapshots.py @@ -0,0 +1,102 @@ +from carta import _enum_snapshots as enum_snapshots + + +def mismatch_by_name(result, name): + return next(mismatch for mismatch in result if mismatch["name"] == name) + + +def test_build_constant_enum_dict_discovers_frontend_enums_and_skips_python_only_helpers(): + enum_dict = enum_snapshots.build_constant_enum_dict() + + assert "Scaling" in enum_dict + assert enum_dict["Scaling"]["snapshot_name"] == "FrameScaling" + assert enum_dict["Scaling"]["enum"] is enum_snapshots.constants.Scaling + assert "RegionType" in enum_dict + assert enum_dict["RegionType"]["snapshot_name"] == "protobuf:RegionType" + assert "ImageType" in enum_dict + assert enum_dict["ImageType"]["snapshot_name"] == "ImageType" + assert enum_snapshots.constants.FrontendEnum.ENUMS["ColorMap"] is enum_snapshots.constants.Colormap + assert enum_snapshots.constants.ProtobufEnum.ENUMS["protobuf:RegionType"] is enum_snapshots.constants.RegionType + assert enum_snapshots.constants.SmoothingMode.SNAPSHOT_NAME == "protobuf:SmoothingMode" + assert "SNAPSHOT_NAME" not in enum_snapshots.constants.SmoothingMode.__members__ + assert "ColormapSet" in enum_dict + assert "Auto" not in enum_dict + assert "ComplexComponent" not in enum_dict + assert "PaletteColor" not in enum_dict + + +def test_find_enum_snapshot_mismatches_returns_empty_list_when_snapshots_match(): + enum_dict = { + "Scaling": { + "snapshot_name": "FrameScaling", + "enum": enum_snapshots.constants.Scaling, + } + } + snapshots = [[ + {"name": member.name, "value": member.value} + for member in enum_snapshots.constants.Scaling + ]] + + assert enum_snapshots.find_enum_snapshot_mismatches(enum_dict, snapshots) == [] + + +def test_find_reports_missing_in_python_and_missing_in_frontend(): + enum_dict = { + "Scaling": { + "snapshot_name": "FrameScaling", + "enum": enum_snapshots.constants.Scaling, + } + } + snapshots = [[ + {"name": "LINEAR", "value": 0}, + {"name": "LOG", "value": 1}, + {"name": "EXPERIMENTAL", "value": 99}, + ]] + + result = enum_snapshots.find_enum_snapshot_mismatches(enum_dict, snapshots) + + mismatch = mismatch_by_name(result, "Scaling") + assert mismatch["name"] == "Scaling" + assert mismatch["source"] == "FrameScaling" + assert mismatch["missing_in_python"] == [ + {"frontend_name": "EXPERIMENTAL", "frontend_value": 99} + ] + assert mismatch["missing_in_frontend"] == [ + {"python_name": "ASINH", "python_value": 9}, + {"python_name": "CUSTOM", "python_value": 7}, + {"python_name": "EXP", "python_value": 6}, + {"python_name": "GAMMA", "python_value": 5}, + {"python_name": "POWER", "python_value": 4}, + {"python_name": "SINH", "python_value": 8}, + {"python_name": "SQRT", "python_value": 2}, + {"python_name": "SQUARE", "python_value": 3}, + ] + assert mismatch["value_mismatches"] == [] + + +def test_find_reports_value_mismatches(): + enum_dict = { + "ContourDashMode": { + "snapshot_name": "ContourDashMode", + "enum": enum_snapshots.constants.ContourDashMode, + } + } + snapshots = [[ + {"name": "None", "value": "None"}, + {"name": "Dashed", "value": "Dashed"}, + {"name": "NegativeOnly", "value": "NegativeOnly"}, + ]] + + result = enum_snapshots.find_enum_snapshot_mismatches(enum_dict, snapshots) + + mismatch = mismatch_by_name(result, "ContourDashMode") + assert mismatch["missing_in_python"] == [] + assert mismatch["missing_in_frontend"] == [] + assert mismatch["value_mismatches"] == [ + { + "python_name": "NEGATIVE_ONLY", + "frontend_name": "NegativeOnly", + "python_value": "Negative only", + "frontend_value": "NegativeOnly", + } + ]