diff --git a/.github/workflows/python-frontend-compatibility.yml b/.github/workflows/python-frontend-compatibility.yml new file mode 100644 index 0000000..d32e850 --- /dev/null +++ b/.github/workflows/python-frontend-compatibility.yml @@ -0,0 +1,69 @@ +name: carta-frontend compatibility + +on: + push: + branches: [ dev ] + pull_request: + branches: [ dev ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - name: Checkout carta-python + uses: actions/checkout@v6 + + - name: Checkout carta-frontend checker + uses: actions/checkout@v6 + with: + repository: CARTAvis/carta-frontend + ref: dev + path: carta-frontend + submodules: recursive + + - name: Install uv and set Python version + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.11" + enable-cache: true + + - name: Sync Python environment + run: uv sync --locked --no-group dev + + - name: Generate carta-python manifests + run: | + uv run --no-sync python scripts/extract_api.py \ + --check \ + --write-manifest \ + --output "$RUNNER_TEMP/carta-python-api.json" + uv run --no-sync python scripts/extract_enum.py \ + --write-manifest \ + --output "$RUNNER_TEMP/carta-python-enum.json" + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: carta-frontend/package-lock.json + + - name: Install carta-frontend dependencies + working-directory: carta-frontend + run: npm ci --ignore-scripts + + - name: Build carta-frontend protobuf types + working-directory: carta-frontend + run: npm run build-protobuf + + - name: Check carta-python API manifest + working-directory: carta-frontend + run: npm run check-python-api -- --manifest "$RUNNER_TEMP/carta-python-api.json" + + - name: Check carta-python enum manifest + working-directory: carta-frontend + run: npm run check-python-enum -- --manifest "$RUNNER_TEMP/carta-python-enum.json" diff --git a/carta/color_blending.py b/carta/color_blending.py index d865748..9172823 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -515,7 +515,7 @@ def set_raster_visible(self, state): state : {0} The desired visibility state. """ - is_visible = self.get_value("rasterVisible") + is_visible = self.get_value("isRasterVisible") if is_visible != state: self.call_action("toggleRasterVisible") @@ -528,7 +528,7 @@ def set_contour_visible(self, state): state : {0} The desired visibility state. """ - is_visible = self.get_value("contourVisible") + is_visible = self.get_value("isContourVisible") if is_visible != state: self.call_action("toggleContourVisible") @@ -541,7 +541,7 @@ def set_vector_overlay_visible(self, state): state : {0} The desired visibility state. """ - is_visible = self.get_value("vectorOverlayVisible") + is_visible = self.get_value("isVectorOverlayVisible") if is_visible != state: self.call_action("toggleVectorOverlayVisible") diff --git a/carta/constants.py b/carta/constants.py index 87dc673..4b5e689 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -11,7 +11,61 @@ class StrEnum(str, Enum): pass -class ComplexComponent(StrEnum): +class _RegisteredEnum: + """Mixin implementation shared by externally defined enum registries.""" + + ENUMS = {} + + def __init_subclass__(cls, *, external_name=None, **kwargs): + """Register an enum subclass by its external name.""" + super().__init_subclass__(**kwargs) + if _RegisteredEnum in cls.__bases__: + return + external_name = external_name or cls.__name__ + if external_name in cls.ENUMS: + raise ValueError(f"Duplicate enum external name {external_name!r} in {cls.__mro__[1].__name__}") + cls.EXTERNAL_NAME = external_name + cls.ENUMS[external_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 = {} + + +class CartaPythonEnum(_RegisteredEnum): + """Mixin for enums defined and used only by carta-python.""" + + ENUMS = {} + + +def _registered_enum(registry, enum_type, enum_name, names, external_name=None, **kwargs): + """Create a functional enum and register it with the requested mixin.""" + enum_ = enum_type(enum_name, names, type=registry, **kwargs) + if external_name is not None: + _set_external_name(enum_, registry, external_name) + return enum_ + + +def _set_external_name(enum_, registry, external_name): + """Set a custom name after EnumMeta has constructed an enum class.""" + existing = registry.ENUMS.get(external_name) + if existing is not None and existing is not enum_: + del registry.ENUMS[enum_.__name__] + raise ValueError(f"Duplicate enum external name {external_name!r} in {registry.__name__}") + del registry.ENUMS[enum_.__name__] + enum_.EXTERNAL_NAME = external_name + registry.ENUMS[external_name] = enum_ + + +class ComplexComponent(CartaPythonEnum, StrEnum): """Complex component.""" AMPLITUDE = "AMPLITUDE" PHASE = "PHASE" @@ -19,52 +73,50 @@ 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')}, external_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'), external_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"}, external_name="SystemType") CoordinateSystem.__doc__ = """Coordinate systems.""" -class NumberFormat(StrEnum): +class NumberFormat(FrontendEnum, StrEnum, external_name="NumberFormatType"): """Number formats.""" DEGREES = "d" HMS = "hms" DMS = "dms" -class SpatialAxis(StrEnum): +class SpatialAxis(CartaPythonEnum, StrEnum): """Spatial axes.""" X = "x" 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" @@ -116,7 +168,7 @@ class BeamType(StrEnum): } -class PaletteColor(StrEnum): +class PaletteColor(CartaPythonEnum, StrEnum): """Palette colours used for WCS overlay elements. Members of this enum class have additional attributes. @@ -142,34 +194,32 @@ def __init__(self, value): PaletteColor[c] = f"auto-{c.lower()}" -Overlay = StrEnum('Overlay', [(c.upper(), c) for c in ("global", "title", "grid", "border", "ticks", "axes", "numbers", "labels", "colorbar")] + [('BEAM', 'beam.settingsForDisplay')]) +Overlay = _registered_enum(CartaPythonEnum, StrEnum, 'Overlay', [(c.upper(), c) for c in ("global", "title", "grid", "border", "ticks", "axes", "numbers", "labels", "colorbar")] + [('BEAM', 'beam.settingsForDisplay')]) Overlay.__doc__ = """WCS overlay elements. Member values are paths to stores corresponding to these elements, relative to the WCS overlay store. """ - - -class SmoothingMode(IntEnum): +class SmoothingMode(ProtobufEnum, IntEnum, external_name="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.""" -class Auto(StrEnum): +class Auto(CartaPythonEnum, StrEnum): """Special value for parameters to be calculated automatically.""" 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 +243,8 @@ class ContourDashMode(StrEnum): } -class Polarization(IntEnum): - """Polarizations, corresponding to the POLARIZATIONS enum in the frontend.""" - +class Polarization(FrontendEnum, IntEnum, external_name="Polarizations"): + """Polarizations.""" def __init__(self, value): self.proto_index = PROTO_POLARIZATION[self.name] @@ -218,19 +267,20 @@ def __init__(self, value): PANGLE = 17 -class PanelMode(IntEnum): +class PanelMode(CartaPythonEnum, IntEnum): """Panel modes.""" SINGLE = 0 MULTIPLE = 1 -class GridMode(StrEnum): +class GridMode(FrontendEnum, StrEnum, external_name="ImagePanelMode"): """Grid modes.""" DYNAMIC = "dynamic" FIXED = "fixed" + NONE = "none" -class FileType(IntEnum): +class FileType(ProtobufEnum, IntEnum, external_name="FileType"): """File types corresponding to the protobuf enum.""" CASA = 0 CRTF = 1 @@ -241,9 +291,8 @@ class FileType(IntEnum): UNKNOWN = 6 -class RegionType(IntEnum): +class RegionType(ProtobufEnum, IntEnum, external_name="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() @@ -253,7 +302,7 @@ def __init__(self, value): POLYLINE = 2 RECTANGLE = 3 ELLIPSE = 4 - # ANNULUS = 5 is not actually implemented + ANNULUS = 5 POLYGON = 6 ANNPOINT = 7 ANNLINE = 8 @@ -267,13 +316,13 @@ def __init__(self, value): ANNCOMPASS = 16 -class CoordinateType(IntEnum): +class CoordinateType(ProtobufEnum, IntEnum, external_name="CoordinateType"): """Coordinate types corresponding to the protobuf enum.""" PIXEL = 0 WORLD = 1 -class PointShape(IntEnum): +class PointShape(ProtobufEnum, IntEnum, external_name="PointAnnotationShape"): """Point annotation shapes corresponding to the protobuf enum.""" SQUARE = 0 BOX = 1 @@ -285,7 +334,7 @@ class PointShape(IntEnum): X = 7 -class TextPosition(IntEnum): +class TextPosition(ProtobufEnum, IntEnum, external_name="TextAnnotationPosition"): """Text annotation positions corresponding to the protobuf enum.""" CENTER = 0 UPPER_LEFT = 1 @@ -298,7 +347,7 @@ class TextPosition(IntEnum): RIGHT = 8 -class AnnotationFontStyle(StrEnum): +class AnnotationFontStyle(FrontendEnum, StrEnum, external_name="FontStyle"): """Font styles which may be used in annotations.""" NORMAL = "Normal" BOLD = "Bold" @@ -306,14 +355,14 @@ class AnnotationFontStyle(StrEnum): BOLD_ITALIC = "Italic Bold" -class AnnotationFont(StrEnum): +class AnnotationFont(FrontendEnum, StrEnum, external_name="Font"): """Fonts which may be used in annotations.""" HELVETICA = "Helvetica" TIMES = "Times" COURIER = "Courier" -class FontFamily(IntEnum): +class FontFamily(CartaPythonEnum, IntEnum): """Font family used in WCS overlay components.""" SANS_SERIF = 0 TIMES = 1 @@ -322,7 +371,7 @@ class FontFamily(IntEnum): COURIER_NEW = 4 -class FontStyle(IntEnum): +class FontStyle(CartaPythonEnum, IntEnum): """Font style used in WCS overlay components.""" NORMAL = 0 ITALIC = 1 @@ -330,14 +379,14 @@ class FontStyle(IntEnum): BOLD_ITALIC = 3 -class ColorbarPosition(StrEnum): +class ColorbarPosition(CartaPythonEnum, StrEnum): """Colorbar positions.""" RIGHT = "right" TOP = "top" BOTTOM = "bottom" -class SpectralSystem(StrEnum): +class SpectralSystem(FrontendEnum, StrEnum): """Spectral systems.""" LSRK = "LSRK" LSRD = "LSRD" @@ -345,7 +394,7 @@ class SpectralSystem(StrEnum): TOPO = "TOPOCENT" -class SpectralUnit(StrEnum): +class SpectralUnit(FrontendEnum, StrEnum): """Spectral units.""" KMS = "km/s" MS = "m/s" @@ -358,9 +407,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 +426,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 +436,7 @@ class SpectralUnit(StrEnum): } -class SpectralType(StrEnum): +class SpectralType(FrontendEnum, StrEnum): """Spectral types. Members of this enum class have additional attributes. @@ -394,10 +452,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/image.py b/carta/image.py index 6c7fade..f8b4baa 100644 --- a/carta/image.py +++ b/carta/image.py @@ -352,7 +352,7 @@ def valid_wcs(self): boolean Whether the image has WCS information. """ - return self.get_value("validWcs") + return self.get_value("isValidWcs") @validate(Coordinate(), Coordinate()) def set_center(self, x, y): diff --git a/carta/preferences.py b/carta/preferences.py index 8e36f2e..796eb76 100644 --- a/carta/preferences.py +++ b/carta/preferences.py @@ -38,6 +38,7 @@ def get(self, name): any value The value of the preference. """ + # carta-api:dynamic path=* return self.get_value(name) @validate(String(), Any()) diff --git a/carta/region.py b/carta/region.py index 02c6ded..662eb87 100644 --- a/carta/region.py +++ b/carta/region.py @@ -12,6 +12,17 @@ from .units import AngularSize +UNSUPPORTED_REGION_TYPES = frozenset({RegionType.ANNULUS}) + + +def _ensure_region_type_supported(region_type): + """Reject region types whose creation workflow is not implemented.""" + region_type = RegionType(region_type) + if region_type in UNSUPPORTED_REGION_TYPES: + raise CartaValidationFailed(f"Region type {region_type.name} is not supported for creation") + return region_type + + class RegionSet(BasePathMixin): """Utility object for collecting region-related image functions. @@ -137,7 +148,7 @@ def add_region(self, region_type, points, rotation=0, name=""): name : {3} The name of the region. Defaults to the empty string. """ - return Region.new(self, region_type, points, rotation, name) + return Region.new(self, _ensure_region_type_supported(region_type), points, rotation, name) def _from_world_coordinates(self, points): """Internal utility function for coercing world or image coordinates to image coordinates. This is used in various region functions to simplify accepting both world and image coordinates. @@ -546,6 +557,9 @@ class Region(BasePathMixin): CUSTOM_CLASS = {} """Mapping of :obj:`carta.constants.RegionType` types to region and annotation classes. This mapping is used to select the appropriate subclass when a region or annotation object is constructed in the wrapper.""" + FRONTEND_RUNTIME_TYPE = "RegionStore" + """The frontend runtime class which receives requests for this region.""" + def __init_subclass__(cls, **kwargs): """Automatically register subclasses in mapping from region types to classes.""" super().__init_subclass__(**kwargs) @@ -635,6 +649,7 @@ def new(cls, region_set, region_type, points, rotation=0, name=""): :obj:`carta.region.Region` object The region object. """ + region_type = _ensure_region_type_supported(region_type) points = [Pt(*point) for point in points] region_id = region_set.call_action("addRegionAsync", region_type, points, rotation, name, return_path="regionId") return cls.existing(region_type, region_set, region_id) @@ -1827,6 +1842,7 @@ class PointAnnotation(Region): """A point annotation.""" REGION_TYPES = (RegionType.ANNPOINT,) """The region types corresponding to this class.""" + FRONTEND_RUNTIME_TYPE = "PointAnnotationStore" # GET PROPERTIES @@ -1877,6 +1893,7 @@ class TextAnnotation(HasFontMixin, HasRotationMixin, HasSizeMixin, Region): """A text annotation.""" REGION_TYPES = (RegionType.ANNTEXT,) """The region types corresponding to this class.""" + FRONTEND_RUNTIME_TYPE = "TextAnnotationStore" # GET PROPERTIES @@ -1931,12 +1948,14 @@ class VectorAnnotation(HasPointerMixin, HasEndpointsMixin, HasRotationMixin, Has """A vector annotation.""" REGION_TYPES = (RegionType.ANNVECTOR,) """The region types corresponding to this class.""" + FRONTEND_RUNTIME_TYPE = "VectorAnnotationStore" class CompassAnnotation(HasFontMixin, HasPointerMixin, HasSizeMixin, Region): """A compass annotation.""" REGION_TYPES = (RegionType.ANNCOMPASS,) """The region types corresponding to this class.""" + FRONTEND_RUNTIME_TYPE = "CompassAnnotationStore" # GET PROPERTIES @@ -1988,7 +2007,7 @@ def arrowheads_visible(self): boolean Whether the east arrowhead is visible. """ - return self.get_value("northArrowhead"), self.get_value("eastArrowhead") + return self.get_value("hasNorthArrowhead"), self.get_value("hasEastArrowhead") # SET PROPERTIES @@ -2097,6 +2116,7 @@ class RulerAnnotation(HasFontMixin, HasEndpointsMixin, HasRotationMixin, HasSize """A ruler annotation.""" REGION_TYPES = (RegionType.ANNRULER,) """The region types corresponding to this class.""" + FRONTEND_RUNTIME_TYPE = "RulerAnnotationStore" # GET PROPERTIES @@ -2109,7 +2129,7 @@ def auxiliary_lines_visible(self): boolean Whether the auxiliary lines are visible. """ - return self.get_value("auxiliaryLineVisible") + return self.get_value("isAuxiliaryLineVisible") @property def auxiliary_lines_dash_length(self): diff --git a/carta/vector_overlay.py b/carta/vector_overlay.py index ff4bf6e..4fa5110 100644 --- a/carta/vector_overlay.py +++ b/carta/vector_overlay.py @@ -1,8 +1,8 @@ """This module contains functionality for interacting with the vector overlay of an image. The class in this module should not be instantiated directly. When an image object is created, a vector overlay object is automatically created as a property.""" from .util import logger, Macro, BasePathMixin -from .constants import Colormap, VectorOverlaySource, Auto -from .validation import validate, Number, Color, Constant, Boolean, all_optional, Union, vargs +from .constants import Colormap, VectorOverlaySource, Polarization, Auto +from .validation import validate, Number, Color, Constant, Boolean, OneOf, all_optional, Union, vargs class VectorOverlay(BasePathMixin): @@ -26,13 +26,13 @@ def __init__(self, image): self.session = image.session self._base_path = f"{image._base_path}.vectorOverlayConfig" - @validate(*all_optional(Constant(VectorOverlaySource), Constant(VectorOverlaySource), Boolean(), Number(), Number(), Boolean(), Number(), Boolean(), Number(), Number())) - def configure(self, angular_source=None, intensity_source=None, pixel_averaging_enabled=None, pixel_averaging=None, fractional_intensity=None, threshold_enabled=None, threshold=None, debiasing=None, q_error=None, u_error=None): + @validate(*all_optional(Constant(VectorOverlaySource), Constant(VectorOverlaySource), Number(), Number(), Boolean(), OneOf(Polarization.I, Polarization.PLINEAR), Number(), Boolean(), Number(), Number())) + def configure(self, angular_source=None, intensity_source=None, pixel_averaging=None, fractional_intensity=None, threshold_enabled=None, threshold_option=None, threshold=None, debiasing=None, q_error=None, u_error=None): """Configure vector overlay. All parameters are optional. For each option that is not provided, the value currently set in the frontend will be preserved. Initial frontend settings are noted below. - We deduce some boolean options. For example, providing an explicit pixel averaging width with the **pixel_averaging** parameter will automatically enable pixel averaging unless **pixel_averaging_enabled** is also explicitly set to ``False``. To disable pixel averaging, explicitly set **pixel_averaging_enabled** to ``False``. + We deduce some boolean options. For example, providing an explicit threshold will automatically enable thresholding unless **threshold_enabled** is also explicitly set to ``False``. Providing both Stokes error values will automatically enable debiasing unless **debiasing** is explicitly set to ``False``. Parameters ---------- @@ -40,14 +40,14 @@ def configure(self, angular_source=None, intensity_source=None, pixel_averaging_ The angular source. This is initially set to computed PA if the image contains Stokes information, otherwise to the current image. intensity_source : {1} The intensity source. This is initially set to computed PI if the image contains Stokes information, otherwise to the current image. - pixel_averaging_enabled : {2} - Enable pixel averaging. This is initially enabled if the pixel averaging width is positive. - pixel_averaging : {3} + pixel_averaging : {2} The pixel averaging width in pixels. The initial value can be configured in the frontend preferences (the default is ``4``). - fractional_intensity : {4} + fractional_intensity : {3} Enable fractional polarization intensity. The initial value can be configured in the frontend preferences. By default this is disabled and the absolute polarization intensity is used. - threshold_enabled : {5} + threshold_enabled : {4} Enable threshold. Initially the threshold is disabled. + threshold_option : {5} + Select whether the threshold applies to Stokes I or computed linear polarization. If omitted, the current frontend setting is preserved. threshold : {6} The threshold in Jy/pixels. The initial value is zero. debiasing : {7} @@ -59,10 +59,8 @@ def configure(self, angular_source=None, intensity_source=None, pixel_averaging_ """ # Avoid doing a lot of needless work for a no-op - args = (angular_source, intensity_source, pixel_averaging_enabled, pixel_averaging, fractional_intensity, threshold_enabled, threshold, debiasing, q_error, u_error) + args = (angular_source, intensity_source, pixel_averaging, fractional_intensity, threshold_enabled, threshold_option, threshold, debiasing, q_error, u_error) if any(a is not None for a in args): - if pixel_averaging is not None and pixel_averaging_enabled is None: - pixel_averaging_enabled = True if threshold is not None and threshold_enabled is None: threshold_enabled = True if q_error is not None and u_error is not None and debiasing is None: @@ -77,14 +75,14 @@ def configure(self, angular_source=None, intensity_source=None, pixel_averaging_ for value, attr_name in ( (angular_source, "angularSource"), (intensity_source, "intensitySource"), - (pixel_averaging_enabled, "pixelAveragingEnabled"), (pixel_averaging, "pixelAveraging"), - (fractional_intensity, "fractionalIntensity"), - (threshold_enabled, "thresholdEnabled"), + (fractional_intensity, "isFractionalIntensity"), + (threshold_enabled, "isThresholdEnabled"), (threshold, "threshold"), - (debiasing, "debiasing"), + (debiasing, "isDebiasing"), (q_error, "qError"), (u_error, "uError"), + (threshold_option, "thresholdOption"), ): if value is None: args.append(self.macro("", attr_name)) @@ -201,7 +199,7 @@ def apply(self): self.image.call_action("applyVectorOverlay") @validate(*all_optional(*vargs(configure, set_thickness, set_intensity_range, set_length_range, set_rotation_offset, set_color, set_colormap, set_bias_and_contrast))) - def plot(self, angular_source=None, intensity_source=None, pixel_averaging_enabled=None, pixel_averaging=None, fractional_intensity=None, threshold_enabled=None, threshold=None, debiasing=None, q_error=None, u_error=None, thickness=None, intensity_min=None, intensity_max=None, length_min=None, length_max=None, rotation_offset=None, color=None, colormap=None, bias=None, contrast=None): + def plot(self, angular_source=None, intensity_source=None, pixel_averaging=None, fractional_intensity=None, threshold_enabled=None, threshold_option=None, threshold=None, debiasing=None, q_error=None, u_error=None, thickness=None, intensity_min=None, intensity_max=None, length_min=None, length_max=None, rotation_offset=None, color=None, colormap=None, bias=None, contrast=None): """Configure, style, and apply the vector overlay in a single step. If both a color and a colormap are provided, the colormap will be enabled. @@ -212,14 +210,14 @@ def plot(self, angular_source=None, intensity_source=None, pixel_averaging_enabl The angular source. This is initially set to computed PA if the image contains Stokes information, otherwise to the current image. intensity_source : {1} The intensity source. This is initially set to computed PI if the image contains Stokes information, otherwise to the current image. - pixel_averaging_enabled : {2} - Enable pixel averaging. This is initially enabled if the pixel averaging width is positive. - pixel_averaging : {3} + pixel_averaging : {2} The pixel averaging width in pixels. The initial value can be configured in the frontend preferences (the default is ``4``). - fractional_intensity : {4} + fractional_intensity : {3} Enable fractional polarization intensity. The initial value can be configured in the frontend preferences. By default this is disabled and the absolute polarization intensity is used. - threshold_enabled : {5} + threshold_enabled : {4} Enable threshold. Initially the threshold is disabled. + threshold_option : {5} + Select whether the threshold applies to Stokes I or computed linear polarization. If omitted, the current frontend setting is preserved. threshold : {6} The threshold in Jy/pixels. The initial value is zero. debiasing : {7} @@ -252,7 +250,7 @@ def plot(self, angular_source=None, intensity_source=None, pixel_averaging_enabl changes_made = False for method, args in [ - (self.configure, (angular_source, intensity_source, pixel_averaging_enabled, pixel_averaging, fractional_intensity, threshold_enabled, threshold, debiasing, q_error, u_error)), + (self.configure, (angular_source, intensity_source, pixel_averaging, fractional_intensity, threshold_enabled, threshold_option, threshold, debiasing, q_error, u_error)), (self.set_thickness, (thickness,)), (self.set_intensity_range, (intensity_min, intensity_max)), (self.set_length_range, (length_min, length_max)), diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index 3922106..50f5579 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -89,7 +89,7 @@ def palette_to_rgb(self, color): The RGB value of the palette colour in the session's current theme, as a 6-digit hexadecimal with a leading ``#``. """ color = PaletteColor(color) - if self.session.get_value("darkTheme"): + if self.session.get_value("isDarkTheme"): return color.rgb_dark return color.rgb_light @@ -168,7 +168,7 @@ def custom_color(self): boolean Whether a custom color is applied. """ - return self.get_value("customColor") + return self.get_value("hasCustomColor") @validate(Constant(PaletteColor)) def set_color(self, color): @@ -208,7 +208,7 @@ def custom_text(self): boolean Whether custom text is applied. """ - return self.get_value("customText") + return self.get_value("hasCustomText") @validate(Boolean()) def set_custom_text(self, state): @@ -305,7 +305,7 @@ def visible(self): boolean Whether this component is visible. """ - return self.get_value("visible") + return self.get_value("isVisible") @validate(Boolean()) def set_visible(self, state): @@ -402,7 +402,7 @@ def custom_precision(self): boolean Whether a custom precision is applied. """ - return self.get_value("customPrecision") + return self.get_value("hasCustomPrecision") @validate(Number(min=0)) def set_precision(self, precision): @@ -456,6 +456,7 @@ class Global(HasColor, OverlayComponent): The session object associated with this overlay component. """ COMPONENT = Overlay.GLOBAL + FRONTEND_RUNTIME_TYPE = "OverlayGlobalSettings" @property def tolerance(self): @@ -533,6 +534,7 @@ class Title(HasCustomColor, HasCustomText, HasFont, HasVisibility, ImageWCSConne The session object associated with this overlay component. """ COMPONENT = Overlay.TITLE + FRONTEND_RUNTIME_TYPE = "OverlayTitleSettings" @validate(ImageWCSConnector.ANY_IDS) def text(self, image_ids=None): @@ -575,6 +577,7 @@ class Grid(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): The session object associated with this overlay component. """ COMPONENT = Overlay.GRID + FRONTEND_RUNTIME_TYPE = "OverlayGridSettings" @property def gap(self): @@ -598,7 +601,7 @@ def custom_gap(self): boolean Whether a custom gap is applied. """ - return self.get_value("customGap") + return self.get_value("hasCustomGap") @validate(*all_optional(Number.POSITIVE, Number.POSITIVE)) def set_gap(self, gap_x, gap_y): @@ -641,6 +644,7 @@ class Border(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): The session object associated with this overlay component. """ COMPONENT = Overlay.BORDER + FRONTEND_RUNTIME_TYPE = "OverlayBorderSettings" class Axes(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): @@ -652,6 +656,7 @@ class Axes(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): The session object associated with this overlay component. """ COMPONENT = Overlay.AXES + FRONTEND_RUNTIME_TYPE = "OverlayAxisSettings" class Numbers(HasCustomColor, HasFont, HasVisibility, HasCustomPrecision, OverlayComponent): @@ -663,6 +668,7 @@ class Numbers(HasCustomColor, HasFont, HasVisibility, HasCustomPrecision, Overla The session object associated with this overlay component. """ COMPONENT = Overlay.NUMBERS + FRONTEND_RUNTIME_TYPE = "OverlayNumberSettings" @property def format(self): @@ -692,7 +698,7 @@ def custom_format(self): boolean Whether a custom format is applied. """ - return self.get_value("customFormat") + return self.get_value("hasCustomFormat") @validate(*all_optional(Constant(NumberFormat), Constant(NumberFormat))) def set_format(self, format_x=None, format_y=None): @@ -735,6 +741,7 @@ class Labels(HasCustomColor, HasCustomText, HasFont, HasVisibility, OverlayCompo The session object associated with this overlay component. """ COMPONENT = Overlay.LABELS + FRONTEND_RUNTIME_TYPE = "OverlayLabelSettings" @property def text(self): @@ -781,6 +788,7 @@ class Ticks(HasCustomColor, HasWidth, OverlayComponent): The session object associated with this overlay component. """ COMPONENT = Overlay.TICKS + FRONTEND_RUNTIME_TYPE = "OverlayTickSettings" @property def density(self): @@ -804,7 +812,7 @@ def custom_density(self): boolean Whether a custom density is applied. """ - return self.get_value("customDensity") + return self.get_value("hasCustomDensity") @property def draw_on_all_edges(self): @@ -815,7 +823,7 @@ def draw_on_all_edges(self): boolean Whether the ticks are drawn on all edges. """ - return self.get_value("drawAll") + return self.get_value("shouldDrawAll") @property def minor_length(self): @@ -908,6 +916,8 @@ class ColorbarComponent: The session object associated with this colorbar component. """ + FRONTEND_RUNTIME_TYPE = "OverlayColorbarSettings" + def __init__(self, colorbar): self.colorbar = colorbar self.session = colorbar.session @@ -951,11 +961,16 @@ def get_value(self, path, return_path=None): object The unmodified return value of the colorbar method. """ - def rewrite(m): - before, first, rest = m.groups() - return f"{before}{self.PREFIX}{first.upper()}{rest}" + prefix = self.PREFIX.title() + match = re.match(r"^(is|has)([A-Z].*)", path) + if match: + path = f"{match.group(1)}{prefix}{match.group(2)}" + else: + def rewrite(m): + before, first, rest = m.groups() + return f"{before}{self.PREFIX}{first.upper()}{rest}" - path = re.sub(r"((?:.*\.)?.*?)(.)(.*)", rewrite, path) + path = re.sub(r"((?:.*\.)?.*?)(.)(.*)", rewrite, path) return self.colorbar.get_value(path, return_path=return_path) @@ -1111,6 +1126,7 @@ class Colorbar(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): The gradient subcomponent. """ COMPONENT = Overlay.COLORBAR + FRONTEND_RUNTIME_TYPE = "OverlayColorbarSettings" def __init__(self, overlay): super().__init__(overlay) @@ -1129,7 +1145,7 @@ def interactive(self): boolean Whether the colorbar is interactive. """ - return self.get_value("interactive") + return self.get_value("isInteractive") @property def offset(self): @@ -1198,6 +1214,7 @@ class Beam(ImageWCSConnector, OverlayComponent): The session object associated with this overlay component. """ COMPONENT = Overlay.BEAM + FRONTEND_RUNTIME_TYPE = "OverlayBeamSettings" @validate(ImageWCSConnector.ANY_IDS) def position(self, image_ids=None): diff --git a/docs/source/development.rst b/docs/source/development.rst new file mode 100644 index 0000000..97981cd --- /dev/null +++ b/docs/source/development.rst @@ -0,0 +1,23 @@ +Development +=========== + +Set up a development environment with ``uv``: + +.. code-block:: shell + + uv sync --all-extras + +Run the test suite and documentation build before submitting changes: + +.. code-block:: shell + + uv run pytest + uv run sphinx-build -W -b html docs/source docs/build/html + +Development topics +------------------ + +.. toctree:: + :maxdepth: 1 + + development/api-contract diff --git a/docs/source/development/api-contract.rst b/docs/source/development/api-contract.rst new file mode 100644 index 0000000..99032bf --- /dev/null +++ b/docs/source/development/api-contract.rst @@ -0,0 +1,62 @@ +Frontend API contract +===================== + +The frontend API contract used by ``carta-python`` is extracted from the +wrapper source by ``scripts/extract_api.py``. Run the extractor check after +changing calls to ``call_action`` or ``get_value``: + +.. code-block:: shell + + uv run python scripts/extract_api.py --check + +The manifest can be regenerated explicitly with: + +.. code-block:: shell + + uv run python scripts/extract_api.py --check --write-manifest + +Most API paths should be statically discoverable from the source. Special +cases are marked at the call site with a ``carta-api`` comment. The comment +may be placed on the call itself or on the line immediately before it. + +Legacy APIs +----------- + +Use ``legacy`` when an API is retained only for compatibility with older +frontend versions. The optional ``until`` value records the last frontend +version which needs the API: + +.. code-block:: python + + # carta-api:legacy until=6.1 + self.call_action("oldStore.setValue", value) + +An API marked only as ``legacy`` is included in the manifest as a compatibility +entry. If another call site uses the same API without the annotation, the API +is treated as required. + +Dynamic APIs +------------ + +Use ``dynamic`` when the path is intentionally supplied by the user and cannot +be enumerated statically. The path is relative to the wrapper object's base +path. ``*`` is the default wildcard: + +.. code-block:: python + + # carta-api:dynamic path=* + return self.get_value(name) + +The shorter form is equivalent: + +.. code-block:: python + + # carta-api:dynamic + return self.get_value(name) + +Dynamic annotations should only be used for genuinely open-ended paths. If a +path has a finite set of values, prefer writing those values explicitly so the +extractor can check each API individually. ``plumbing`` methods and frontend +runtime types are not declared with comments: forwarding methods are handled +by the extractor's plumbing rules, while runtime types are declared on their +Python wrapper classes with ``FRONTEND_RUNTIME_TYPE``. diff --git a/docs/source/index.rst b/docs/source/index.rst index 6d4c9fa..8183f17 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,4 +7,5 @@ carta-python: a scripting wrapper for CARTA introduction quickstart + development carta diff --git a/scripts/extract_api.py b/scripts/extract_api.py new file mode 100644 index 0000000..af4c54a --- /dev/null +++ b/scripts/extract_api.py @@ -0,0 +1,598 @@ +#!/usr/bin/env python3 + +"""Extract the carta-frontend actions and parameters used by this wrapper. + +The extraction has two stages: + +1. A static scan of the ``carta`` package with :obj:`ast`, which finds every + ``call_action`` and ``get_value`` call site, the path passed to it, and any + :obj:`carta.util.Macro` arguments. Paths built from f-strings become globs + (``regionMap[*]``), and paths passed in a local variable are resolved with a + small constant propagation pass. + +2. A runtime replay of each path through real wrapper objects, with + :obj:`carta.session.Session.call_action` replaced by a recorder. This uses + the wrapper's own code to prepend base paths, to resolve paths inherited + from mixins, and to insert colorbar component prefixes, so that none of that + logic has to be reimplemented here. No frontend or backend is needed. + +Call sites which the static stage cannot resolve are reported separately, and +fail ``--check``. A call site with a path which is genuinely dynamic, because it +is provided by the user, must be annotated with ``# carta-api:dynamic``. + +The extracted APIs are the two repositories' shared contract. CI generates +``carta-python-api.json`` from this repository and passes it to carta-frontend's +checker to verify that every frontend API used here still exists. +Each entry also records the frontend runtime types which can receive the API, so +polymorphic objects such as annotations can be checked against the correct subtype. + +Usage: extract_api.py [--check] [--write-manifest] [--output FILE] +""" + +import argparse +import ast +import collections +import dataclasses +import json +import pathlib +import re +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from carta.color_blending import ColorBlending # noqa: E402 +from carta.constants import RegionType # noqa: E402 +from carta.image import Image # noqa: E402 +from carta.region import Region, RegionSet # noqa: E402 +from carta.session import Session # noqa: E402 +from carta.util import Macro # noqa: E402 + +ROOT = pathlib.Path(__file__).resolve().parent.parent + +PACKAGE = ROOT / "carta" + +# The machine-readable contract generated for carta-frontend's CI. +MANIFEST = ROOT / "carta-python-api.json" + +WRAPPERS = ("call_action", "get_value") + +# Call sites inside the wrapper's own plumbing, which forward a path from a +# caller instead of naming a frontend API. +PLUMBING = { + ("util.py", "BasePathMixin", "call_action"), + ("util.py", "BasePathMixin", "get_value"), + ("session.py", "Session", "get_value"), + ("wcs_overlay.py", "ColorbarComponent", "call_action"), + ("wcs_overlay.py", "ColorbarComponent", "get_value"), +} + +# Receiver expressions, mapped to the name of the class of the object they +# evaluate to. A call site is replayed on every registered object of that class, +# so a call site in a mixin is replayed on every class which uses the mixin. +# `self` means the class which contains the call site. +RECEIVERS = { + "self": "self", + "session": "Session", + "self.session": "Session", + "self.image": "Image", + "self.color_blending": "ColorBlending", + "self.colorbar": "Colorbar", + "region_set": "RegionSet", + "self.region_set": "RegionSet", + "self.active_frame().regions": "RegionSet", +} + +ID_INDEX = re.compile(r"\[\d+\]") +LEGACY_ANNOTATION = re.compile(r"#\s*carta-api:legacy(?:\s+until=(\S+))?\s*$") +DYNAMIC_ANNOTATION = re.compile(r"#\s*carta-api:dynamic(?:\s+path=(\S+))?\s*$") + + +@dataclasses.dataclass +class Site: + """A single ``call_action`` or ``get_value`` call site in the wrapper.""" + + module: str + line: int + clazz: str + method: str + wrapper: str + receiver: str + path: str + exact: bool + return_path: str + args: tuple = () + dynamic: bool = False + legacy: bool = False + legacy_until: str = "" + + @property + def location(self): + """The source location of this call site.""" + return f"carta/{self.module}:{self.line}{' (dynamic)' if self.dynamic else ''}" + + @property + def qualname(self): + """The qualified name of the wrapper method which contains this call site.""" + return f"{self.clazz}.{self.method}" if self.clazz else self.method + + +@dataclasses.dataclass +class Api: + """A frontend action, parameter or store object used by the wrapper.""" + + kind: str + path: str + exact: bool = True + return_path: str = "" + sites: list = dataclasses.field(default_factory=list) + runtime_types: set = dataclasses.field(default_factory=set) + required: bool = False + legacy_until: str = "" + + def add(self, site, runtime_types=()): + """Record a call site which uses this frontend API.""" + if site not in self.sites: + self.sites.append(site) + self.exact &= site.exact + self.runtime_types.update(runtime_types) + if site.legacy: + if not self.required and site.legacy_until: + self.legacy_until = site.legacy_until + else: + self.required = True + self.legacy_until = "" + + +class Scanner(ast.NodeVisitor): + """Collects the ``call_action`` and ``get_value`` call sites in one module.""" + + def __init__(self, module, source): + self.module = module + self.source_lines = source.splitlines() + self.sites = [] + self.unresolved = [] + self.classes = [] + self.functions = [] + self.assignments = [{}] + + @property + def clazz(self): + """The class currently being scanned.""" + return self.classes[-1] if self.classes else "" + + @property + def method(self): + """The function currently being scanned.""" + return self.functions[-1] if self.functions else "" + + def legacy_annotation(self, node): + """Return legacy compatibility metadata attached to a call site.""" + line_numbers = [node.lineno] + if node.lineno > 1: + line_numbers.append(node.lineno - 1) + for line_number in line_numbers: + match = LEGACY_ANNOTATION.search(self.source_lines[line_number - 1]) + if match: + return True, match.group(1) or "" + return False, "" + + def dynamic_annotation(self, node): + """Return the dynamic path attached to a call site, if any.""" + line_numbers = [node.lineno] + if node.lineno > 1: + line_numbers.append(node.lineno - 1) + for line_number in line_numbers: + match = DYNAMIC_ANNOTATION.search(self.source_lines[line_number - 1]) + if match: + return match.group(1) or "*" + return None + + def visit_ClassDef(self, node): + """Scan a class definition.""" + self.classes.append(node.name) + self.generic_visit(node) + self.classes.pop() + + def visit_FunctionDef(self, node): + """Scan a function definition.""" + self.functions.append(node.name) + self.assignments.append(self.local_assignments(node)) + self.generic_visit(node) + self.assignments.pop() + self.functions.pop() + + @staticmethod + def local_assignments(node): + """Map each local variable in a function to the values bound to it. + + Both assignments and ``for`` loops over literal iterables are followed, + which is enough to resolve the paths and macro attribute names which the + wrapper builds up in local variables. + """ + assignments = collections.defaultdict(list) + + for child in ast.walk(node): + if isinstance(child, ast.Assign) and len(child.targets) == 1 and isinstance(child.targets[0], ast.Name): + values = [child.value.body, child.value.orelse] if isinstance(child.value, ast.IfExp) else [child.value] + assignments[child.targets[0].id].extend(values) + elif isinstance(child, ast.Call) and getattr(child.func, "attr", "") in ("append", "extend") and isinstance(child.func.value, ast.Name): + assignments[child.func.value.id].extend(child.args) + elif isinstance(child, ast.For): + targets = child.target.elts if isinstance(child.target, ast.Tuple) else [child.target] + for item in getattr(child.iter, "elts", []): + values = item.elts if isinstance(item, ast.Tuple) else [item] + for target, value in zip(targets, values) if len(targets) == len(values) else (): + if isinstance(target, ast.Name): + assignments[target.id].append(value) + + return assignments + + def visit_Call(self, node): + """Scan a call, and record it if it is a wrapper call.""" + self.generic_visit(node) + + if not isinstance(node.func, ast.Attribute) or node.func.attr not in WRAPPERS: + return + if (self.module, self.clazz, self.method) in PLUMBING: + return + + receiver = ast.unparse(node.func.value) + return_path = "" + for keyword in node.keywords: + if keyword.arg == "return_path" and isinstance(keyword.value, ast.Constant): + return_path = keyword.value.value + + args = tuple(self.macro_args(node.args[1:])) + paths, exact = self.paths(node.args[0] if node.args else None) + + dynamic = self.dynamic_annotation(node) + if dynamic is not None: + paths, exact = [dynamic], False + + if not paths or receiver not in RECEIVERS: + self.unresolved.append((f"carta/{self.module}:{node.lineno}", self.clazz, self.method, ast.unparse(node))) + return + + legacy, legacy_until = self.legacy_annotation(node) + for path in paths: + self.sites.append( + Site( + self.module, + node.lineno, + self.clazz, + self.method, + node.func.attr, + receiver, + path, + exact, + return_path, + args, + dynamic is not None, + legacy, + legacy_until, + ) + ) + + def values(self, node): + """The expressions an argument may evaluate to. + + A local variable, or a variable unpacked with ``*``, is expanded to every + value bound to it in the enclosing function; any other expression is + returned unchanged. + """ + node = node.value if isinstance(node, ast.Starred) else node + return self.assignments[-1][node.id] if isinstance(node, ast.Name) else [node] + + def strings(self, node): + """The string constants an argument may evaluate to.""" + strings = [] + for value in self.values(node): + if isinstance(value, ast.Constant) and isinstance(value.value, str): + strings.append(value.value) + elif isinstance(value, (ast.List, ast.Tuple)): + strings.extend( + element.value + for element in value.elts + if isinstance(element, ast.Constant) and isinstance(element.value, str) + ) + return strings + + def macro_args(self, nodes): + """Descriptors for the arguments of a call site which are frontend macros. + + A macro is either an attribute such as ``self._frame``, a call to the + ``macro`` method of a wrapper object, or a :obj:`carta.util.Macro` + constructed directly. + """ + for node in nodes: + for value in self.values(node): + if isinstance(value, ast.Attribute) and value.attr in ("_frame", "_region"): + yield ("attr", ast.unparse(value)) + continue + if not isinstance(value, ast.Call) or len(value.args) != 2: + continue + name = getattr(value.func, "attr", getattr(value.func, "id", "")) + if name not in ("macro", "Macro"): + continue + owner = "" if name == "Macro" else ast.unparse(value.func.value) + for target in self.strings(value.args[0]): + for variable in self.strings(value.args[1]): + yield ("macro", owner, target, variable) + + def paths(self, node): + """The possible paths for the first argument of a call site. + + Returns the paths and whether they are exact. An inexact path is a glob + in which each interpolated value has been replaced by ``*``. + """ + if isinstance(node, ast.JoinedStr): + return ["".join(v.value if isinstance(v, ast.Constant) else "*" for v in node.values)], False + return self.strings(node) if node is not None else [], True + + +class Registry: + """Real wrapper objects, used to resolve the base path of each call site.""" + + def __init__(self): + self.session = Session(0, None) + self.objects = [] + self.by_class = collections.defaultdict(list) + self.seen = set() + + image = Image(self.session, 0) + for obj in (self.session, image, ColorBlending(self.session, 0)): + self.collect(obj) + for region_type in RegionType: + self.collect(Region.region_class(region_type)(image.regions, 0)) + + def collect(self, obj): + """Recursively register wrapper objects reachable from an object.""" + if id(obj) in self.seen or type(obj).__module__.split(".")[0] != "carta": + return + self.seen.add(id(obj)) + + if any(hasattr(obj, wrapper) for wrapper in WRAPPERS): + self.objects.append(obj) + for clazz in type(obj).__mro__: + self.by_class[clazz.__name__].append(obj) + + for value in list(vars(obj).values()): + for item in value.values() if isinstance(value, dict) else [value]: + self.collect(item) + + def instances(self, site): + """The registered objects on which a call site should be replayed.""" + clazz = site.clazz if RECEIVERS[site.receiver] == "self" else RECEIVERS[site.receiver] + return [o for o in self.by_class[clazz] if hasattr(o, site.wrapper)] + + +def resolve_object(path, instance): + """Resolve a dotted attribute path rooted at ``self`` to a wrapper object.""" + obj = instance + for attr in path.split(".")[1:]: + obj = getattr(obj, attr, None) + return obj + + +def resolve_macro(descriptor, instance): + """Resolve a macro argument descriptor to a :obj:`carta.util.Macro`.""" + if descriptor[0] == "attr": + owner, _, attr = descriptor[1].rpartition(".") + value = getattr(resolve_object(owner, instance), attr, None) + return value if isinstance(value, Macro) else None + + _, owner, target, variable = descriptor + if not owner: + return Macro(target, variable) + obj = resolve_object(owner, instance) + return obj.macro(target, variable) if obj is not None else None + + +def macro_path(macro): + """The generic path of a macro.""" + path = f"{macro.target}.{macro.variable}" if macro.target else macro.variable + return ID_INDEX.sub("[*]", path) + + +def request_path(path, args): + """The kind and generic path of a recorded frontend request.""" + if path == "fetchParameter" and args and isinstance(args[0], Macro): + return "parameter", macro_path(args[0]) + return "action", ID_INDEX.sub("[*]", path) + + +def frontend_runtime_type(instance): + """Return the frontend runtime type declared by a wrapper object.""" + runtime_type = getattr(instance, "FRONTEND_RUNTIME_TYPE", None) + if runtime_type is None: + raise ValueError( + f"{type(instance).__name__} does not declare FRONTEND_RUNTIME_TYPE" + ) + return runtime_type + + +def region_runtime_types(instance): + """Return the runtime types which may receive a region request. + + A concrete region object declares one type. ``RegionSet`` can address an + arbitrary region ID, so derive the possible types from the registered + region classes instead of maintaining a second mapping here. + """ + instance_type = type(instance) + runtime_type = instance_type.__dict__.get("FRONTEND_RUNTIME_TYPE") + if runtime_type is not None and instance_type is not Region: + return [runtime_type] + if isinstance(instance, (Region, RegionSet)): + region_classes = set(Region.CUSTOM_CLASS.values()) + missing = [ + clazz.__name__ + for clazz in region_classes + if all(region_type.name.startswith("ANN") for region_type in getattr(clazz, "REGION_TYPES", ())) + and "FRONTEND_RUNTIME_TYPE" not in clazz.__dict__ + ] + if missing: + names = ", ".join(sorted(missing)) + raise ValueError(f"Annotation classes do not declare FRONTEND_RUNTIME_TYPE: {names}") + return sorted({frontend_runtime_type(Region), *(frontend_runtime_type(clazz) for clazz in region_classes if "FRONTEND_RUNTIME_TYPE" in clazz.__dict__)}) + raise ValueError( + f"{type(instance).__name__} does not declare FRONTEND_RUNTIME_TYPE" + ) + + +def frontend_runtime_types(path, instance): + """Return the frontend class types which can receive a recorded path.""" + region_prefix = "frameMap[*].regionSet.regionMap[*]" + if path == region_prefix or path.startswith(f"{region_prefix}."): + return region_runtime_types(instance) + + if path == "frameMap[*]" or path.startswith("frameMap[*].") or path == "activeFrame" or path.startswith("activeFrame."): + return ["FrameStore"] + + if path == "overlaySettings": + return ["OverlaySettings"] + if path.startswith("overlaySettings."): + return [frontend_runtime_type(instance)] + + root_types = { + "backendService": "BackendService", + "fileBrowserStore": "FileBrowserStore", + "preferenceStore": "PreferenceStore", + "widgetsStore": "WidgetsStore", + } + root = path.split(".", 1)[0] + return [root_types[root]] if root in root_types else ["AppStore"] + + +def replay(registry, sites): + """Resolve the full frontend path of each call site by replaying it. + + Each path is passed to the real wrapper method of a real wrapper object, + with :obj:`carta.session.Session.call_action` replaced by a recorder, so + that base paths, mixins and prefix rewriting are resolved by the wrapper + itself. + """ + recorded = [] + original = Session.call_action + Session.call_action = lambda self, path, *args, **kwargs: recorded.append((path, args, kwargs)) + + apis = {} + + def add(kind, path, site, return_path="", runtime_types=()): + apis.setdefault((kind, path), Api(kind, path, return_path=return_path)).add(site, runtime_types) + + try: + for site in sites: + for instance in registry.instances(site): + recorded.clear() + getattr(instance, site.wrapper)(site.path, return_path=site.return_path or None) + for path, args, kwargs in recorded: + kind, full_path = request_path(path, args) + add(kind, full_path, site, site.return_path, frontend_runtime_types(full_path, instance)) + + # Macro arguments are resolved on the object which contains the call + # site, which is not necessarily the object being called. + for owner in registry.by_class[site.clazz]: + for node in site.args: + macro = resolve_macro(node, owner) + if macro is not None: + full_path = macro_path(macro) + add("reference", full_path, site, runtime_types=frontend_runtime_types(full_path, owner)) + finally: + Session.call_action = original + + return apis + + +def scan(): + """Scan the package and return its call sites and unresolved call sites.""" + sites, unresolved = [], [] + + for path in sorted(PACKAGE.rglob("*.py")): + source = path.read_text(encoding="utf-8") + scanner = Scanner(path.relative_to(PACKAGE).as_posix(), source) + scanner.visit(ast.parse(source, filename=str(path))) + sites.extend(scanner.sites) + unresolved.extend(scanner.unresolved) + + return sites, unresolved + + +def manifest(apis): + """The contract manifest of the frontend APIs which the wrapper uses. + + The manifest deliberately omits the source locations of the call sites, so + that it changes only when the frontend API surface which the wrapper uses + changes, and not whenever an unrelated edit shifts a line. + """ + entries = [] + for api in apis: + entry = { + "kind": api.kind, + "path": api.path, + "exact": api.exact, + "return_path": api.return_path, + "runtime_types": sorted(api.runtime_types), + "wrappers": sorted({s.qualname for s in api.sites}), + } + if not api.required: + entry["compatibility"] = "legacy" + if api.legacy_until: + entry["until"] = api.legacy_until + entries.append(entry) + return {"apis": entries} + + +def dump_manifest(data): + """The canonical serialisation of the manifest.""" + return json.dumps(data, indent=2) + "\n" + + +def print_report(apis, unresolved, sites, objects): + """Print every frontend API, and any unresolved call sites, as text.""" + for kind in ("action", "parameter", "reference"): + selected = [a for a in apis if a.kind == kind] + print(f"\n{kind.upper()}S ({len(selected)})\n") + for api in selected: + suffix = f" -> {api.return_path}" if api.return_path else "" + compatibility = " [legacy" + (f" until={api.legacy_until}" if api.legacy_until else "") + "]" if not api.required else "" + print(f" {api.path}{suffix}{'' if api.exact else ' [glob]'}{compatibility}") + print(f"\nUNRESOLVED CALL SITES ({len(unresolved)})\n") + for location, clazz, method, source in unresolved: + print(f" {location} {clazz}.{method}\n {source}") + + print(f"\n{len(sites)} resolved call sites, {len(apis)} distinct frontend APIs, {objects} wrapper objects") + + +def main(): + """Extract the frontend APIs and print a report.""" + parser = argparse.ArgumentParser(description="Extract the carta-frontend APIs used by this wrapper.") + parser.add_argument("--check", action="store_true", help="exit with an error if any call site is unresolved") + parser.add_argument("--write-manifest", action="store_true", help=f"write the contract manifest to {MANIFEST.name}") + parser.add_argument("--output", metavar="FILE", help="write the manifest to FILE instead of the default path") + args = parser.parse_args() + + if args.output and not args.write_manifest: + parser.error("--output requires --write-manifest") + + sites, unresolved = scan() + registry = Registry() + apis = replay(registry, sites) + ordered = sorted(apis.values(), key=lambda a: (a.kind, a.path)) + data = manifest(ordered) + + failed = bool(unresolved) and args.check + + if args.write_manifest: + output = pathlib.Path(args.output) if args.output else MANIFEST + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(dump_manifest(data)) + print(f"Wrote {len(data['apis'])} frontend APIs to {output}.") + + if not args.write_manifest: + print_report(ordered, unresolved, sites, len(registry.objects)) + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/extract_enum.py b/scripts/extract_enum.py new file mode 100644 index 0000000..4c427ac --- /dev/null +++ b/scripts/extract_enum.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +"""Extract carta-python enum contracts for carta-frontend CI. + +The manifest records all public enums owned by carta-python and the subset +which is defined by the CARTA frontend or protobuf declarations. Frontend +and protobuf names are kept unqualified; their manifest section identifies the +source. +""" + +import argparse +import json +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from carta import constants # noqa: E402 + + +ROOT = pathlib.Path(__file__).resolve().parent.parent +MANIFEST = ROOT / "carta-python-enum.json" + +REGISTRIES = ( + ("carta-python", constants.CartaPythonEnum), + ("frontend", constants.FrontendEnum), + ("protobuf", constants.ProtobufEnum), +) + + +def enum_members(enum): + """Return a canonical, JSON-serialisable representation of an enum.""" + return [ + {"name": name, "value": member.value} + for name, member in sorted(enum.__members__.items()) + ] + + +def manifest(): + """Return the canonical enum contract.""" + return { + "enums": { + source: { + name: enum_members(enum) + for name, enum in sorted(registry.ENUMS.items()) + } + for source, registry in REGISTRIES + }, + } + + +def dump_manifest(data): + """Serialise a manifest canonically.""" + return json.dumps(data, indent=2) + "\n" + + +def main(): + """Run the enum manifest generator.""" + parser = argparse.ArgumentParser(description="Extract carta-python enum values for carta-frontend CI.") + parser.add_argument("--write-manifest", action="store_true", help=f"write the contract manifest to {MANIFEST.name}") + parser.add_argument("--output", metavar="FILE", help="write the manifest to FILE instead of the default path") + args = parser.parse_args() + + if args.output and not args.write_manifest: + parser.error("--output requires --write-manifest") + + data = manifest() + + if args.write_manifest: + output = pathlib.Path(args.output) if args.output else MANIFEST + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(dump_manifest(data)) + counts = { + source: sum(len(members) for members in enums.values()) + for source, enums in data["enums"].items() + } + total = sum(counts.values()) + breakdown = ", ".join(f"{source}: {count}" for source, count in counts.items()) + print(f"Wrote {total} enum values to {output} ({breakdown}).") + + if not args.write_manifest: + print(dump_manifest(data), end="") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index dbc35da..b19deb6 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -664,15 +664,15 @@ def test_color_blending_set_alphas_length_mismatch(color_blending, mocker, vals) @pytest.mark.parametrize( "getter,method,action,state", [ - ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), + ("isRasterVisible", "set_raster_visible", "toggleRasterVisible", True), ( - "contourVisible", + "isContourVisible", "set_contour_visible", "toggleContourVisible", True, ), ( - "vectorOverlayVisible", + "isVectorOverlayVisible", "set_vector_overlay_visible", "toggleVectorOverlayVisible", False, @@ -691,15 +691,15 @@ def test_color_blending_toggle_visibility_when_needed( @pytest.mark.parametrize( "getter,method,action,state", [ - ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), + ("isRasterVisible", "set_raster_visible", "toggleRasterVisible", True), ( - "contourVisible", + "isContourVisible", "set_contour_visible", "toggleContourVisible", False, ), ( - "vectorOverlayVisible", + "isVectorOverlayVisible", "set_vector_overlay_visible", "toggleVectorOverlayVisible", True, diff --git a/tests/test_extract_api.py b/tests/test_extract_api.py new file mode 100644 index 0000000..ff747fb --- /dev/null +++ b/tests/test_extract_api.py @@ -0,0 +1,104 @@ +import importlib.util +import json +import pathlib + +from carta import constants +from carta.region import TextAnnotation +from carta.wcs_overlay import ColorbarComponent, Global + + +SCRIPT = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "extract_api.py" + + +def load_script(): + spec = importlib.util.spec_from_file_location("extract_api", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +extract = load_script() + + +def test_manifest_is_generated_without_version_or_deprecation_data(): + sites, unresolved = extract.scan() + assert unresolved == [] + + apis = extract.replay(extract.Registry(), sites) + data = extract.manifest(sorted(apis.values(), key=lambda api: (api.kind, api.path))) + + assert set(data) == {"apis"} + assert data["apis"] + assert [(api["kind"], api["path"]) for api in data["apis"]] == sorted( + (api["kind"], api["path"]) for api in data["apis"] + ) + assert any(api["path"] == "getImageDataUrl" for api in data["apis"]) + assert all("MINIMUM_CARTA_VERSION" not in api for api in data["apis"]) + + +def test_legacy_annotation_marks_an_api_as_compatibility_only(): + source = """\ +class Example: + def update(self): + # carta-api:legacy until=6.1 + self.call_action("oldStore.setFoo") +""" + scanner = extract.Scanner("example.py", source) + scanner.visit(extract.ast.parse(source)) + + assert scanner.unresolved == [] + assert len(scanner.sites) == 1 + assert scanner.sites[0].legacy is True + assert scanner.sites[0].legacy_until == "6.1" + + api = extract.Api("action", "oldStore.setFoo") + api.add(scanner.sites[0]) + entry = extract.manifest([api])["apis"][0] + assert entry["compatibility"] == "legacy" + assert entry["until"] == "6.1" + + +def test_dynamic_annotation_declares_a_wildcard_api(): + source = """\ +class Example: + def get(self, name): + # carta-api:dynamic path=* + return self.get_value(name) +""" + scanner = extract.Scanner("example.py", source) + scanner.visit(extract.ast.parse(source)) + + assert scanner.unresolved == [] + assert len(scanner.sites) == 1 + assert scanner.sites[0].path == "*" + assert scanner.sites[0].exact is False + assert scanner.sites[0].dynamic is True + + +def test_manifest_serialisation_is_canonical(): + data = {"apis": [{"kind": "action", "path": "setFoo"}]} + dumped = extract.dump_manifest(data) + + assert dumped.endswith("\n") + assert json.loads(dumped) == data + + +def test_registry_contains_expected_python_wrapper_types(): + registry = extract.Registry() + + assert registry.by_class["Session"] + assert registry.by_class["Image"] + assert constants.RegionType.POINT in constants.RegionType + + +def test_frontend_runtime_types_are_declared_on_wrapper_classes(): + assert TextAnnotation.FRONTEND_RUNTIME_TYPE == "TextAnnotationStore" + assert Global.FRONTEND_RUNTIME_TYPE == "OverlayGlobalSettings" + assert ColorbarComponent.FRONTEND_RUNTIME_TYPE == "OverlayColorbarSettings" + + assert extract.frontend_runtime_types( + "frameMap[*].regionSet.regionMap[*].setText", object.__new__(TextAnnotation) + ) == ["TextAnnotationStore"] + assert extract.frontend_runtime_types( + "overlaySettings.global.setColor", object.__new__(Global) + ) == ["OverlayGlobalSettings"] diff --git a/tests/test_extract_enum.py b/tests/test_extract_enum.py new file mode 100644 index 0000000..8b0070c --- /dev/null +++ b/tests/test_extract_enum.py @@ -0,0 +1,92 @@ +import importlib.util +import json +import pathlib +from enum import IntEnum + +import pytest + +from carta import constants + + +SCRIPT = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "extract_enum.py" + + +def load_script(): + spec = importlib.util.spec_from_file_location("extract_enum", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +extract = load_script() + + +def test_manifest_groups_enum_sources_and_keeps_protobuf_names_unqualified(): + data = extract.manifest() + + assert set(data) == {"enums"} + assert set(data["enums"]) == {"carta-python", "frontend", "protobuf"} + assert "FontStyle" in data["enums"]["carta-python"] + assert "FontStyle" in data["enums"]["frontend"] + assert "RegionType" in data["enums"]["protobuf"] + assert "protobuf:RegionType" not in data["enums"]["protobuf"] + assert {member["name"] for member in data["enums"]["protobuf"]["RegionType"]} == { + "POINT", + "LINE", + "POLYLINE", + "RECTANGLE", + "ELLIPSE", + "ANNULUS", + "POLYGON", + "ANNPOINT", + "ANNLINE", + "ANNPOLYLINE", + "ANNRECTANGLE", + "ANNELLIPSE", + "ANNPOLYGON", + "ANNVECTOR", + "ANNRULER", + "ANNTEXT", + "ANNCOMPASS", + } + + +def test_protobuf_registry_owns_source_identification(): + assert constants.SmoothingMode.EXTERNAL_NAME == "SmoothingMode" + assert constants.ProtobufEnum.ENUMS["SmoothingMode"] is constants.SmoothingMode + assert "protobuf:SmoothingMode" not in constants.ProtobufEnum.ENUMS + assert "EXTERNAL_NAME" not in constants.SmoothingMode.__members__ + + +def test_manifest_is_canonical(): + data = extract.manifest() + dumped = extract.dump_manifest(data) + + assert dumped.endswith("\n") + assert json.loads(dumped) == data + + +def test_enum_members_include_aliases(): + class AliasEnum(IntEnum): + FIRST = 1 + ALSO_FIRST = 1 + + assert extract.enum_members(AliasEnum) == [ + {"name": "ALSO_FIRST", "value": 1}, + {"name": "FIRST", "value": 1}, + ] + + +def test_enum_registry_rejects_duplicate_external_names(): + with pytest.raises(ValueError, match="Duplicate enum external name"): + class DuplicateFrontendEnum(constants.FrontendEnum, IntEnum, external_name="ColorMap"): + VALUE = 0 + + with pytest.raises(ValueError, match="Duplicate enum external name"): + constants._registered_enum( + constants.FrontendEnum, + IntEnum, + "DuplicateFunctionalEnum", + ("VALUE",), + external_name="ColorMap", + ) diff --git a/tests/test_image.py b/tests/test_image.py index 1627c3b..f2f13ea 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -486,7 +486,7 @@ def test_beam_show_hide(mocker, image, session_call_action): def test_beam_visible(image, session_get_value): session_get_value.side_effect = [True] visible = image.wcs.beam.visible - session_get_value.assert_called_with("frameMap[0].overlayBeamSettings.visible", return_path=None) + session_get_value.assert_called_with("frameMap[0].overlayBeamSettings.isVisible", return_path=None) assert visible diff --git a/tests/test_region.py b/tests/test_region.py index 60b4734..d6990f0 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -3,7 +3,7 @@ from carta.region import Region, HasSizeMixin from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT, AnnotationFontStyle as AFS, AnnotationFont as AF, PointShape as PS, TextPosition as TP, SpatialAxis as SA -from carta.util import Point as Pt, Macro +from carta.util import Point as Pt, Macro, CartaValidationFailed # FIXTURES @@ -159,6 +159,11 @@ def test_regionset_add_region(mocker, image): mock_new.assert_called_with(image.regions, RT.RECTANGLE, [(10, 10), (100, 100)], 90, "name") +def test_regionset_add_annulus_is_rejected(image): + with pytest.raises(CartaValidationFailed, match="ANNULUS"): + image.regions.add_region(RT.ANNULUS, [(10, 10), (100, 100)]) + + @pytest.mark.parametrize("func,args,kwargs,expected_args,expected_kwargs", [ ("add_point", [(10, 10)], {}, [RT.POINT, [(10, 10)]], {"name": ""}), ("add_point", [("10", "10")], {}, [RT.POINT, [(10, 10)]], {"name": ""}), @@ -447,7 +452,7 @@ def test_wcs_center(region, property_, mock_to_world, region_type): assert wcs_center == ("20", "30") -@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT, RT.ANNULUS}) def test_size(region, get_value, region_type): reg = region(region_type) reg_get_value = get_value(reg, {"x": 20, "y": 30}) @@ -461,7 +466,7 @@ def test_size(region, get_value, region_type): assert size == (20, 30) -@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT, RT.ANNULUS}) def test_wcs_size(region, get_value, property_, mock_to_angular, region_type): reg = region(region_type) @@ -500,7 +505,7 @@ def test_common_properties(region, get_value, method_name, value_name): assert value == "dummy" -@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT, RT.ANNULUS}) @pytest.mark.parametrize("method_name,value_name", [ ("line_width", "lineWidth"), ("dash_length", "dashLength"), @@ -556,7 +561,7 @@ def test_translate(region, mock_from_world, mock_from_angular, method, property_ mock_set_center.assert_called_with(expected_value) -@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT, RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT, RT.ANNULUS, RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) @pytest.mark.parametrize("value,expected_value", [ ((20, 30), Pt(20, 30)), ((-20, -30), Pt(20, 30)), @@ -595,7 +600,7 @@ def test_set_size_poly(region, mock_from_angular, method, property_, region_type mock_set_vertices.assert_called_with(expected_value) -@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT, RT.ANNULUS}) def test_scale(region, method, property_, region_type): reg = region(region_type) property_(reg)("size", (20, 30)) @@ -634,7 +639,7 @@ def test_set_color(region, call_action): mock_call.assert_called_with("setColor", "blue") -@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT, RT.ANNULUS}) @pytest.mark.parametrize("args,kwargs,expected_calls", [ ([], {}, []), ([2, 3], {}, [("setLineWidth", 2), ("setDashLength", 3)]), @@ -1018,7 +1023,7 @@ def test_set_text_position(region, call_action): ("labels", ["northLabel", "eastLabel"], ["N", "E"], ("N", "E")), ("point_length", ["length"], [100], 100), ("label_offsets", ["northTextOffset", "eastTextOffset"], [{"x": 1, "y": 2}, {"x": 3, "y": 4}], ((1, 2), (3, 4))), - ("arrowheads_visible", ["northArrowhead", "eastArrowhead"], [True, False], (True, False)), + ("arrowheads_visible", ["hasNorthArrowhead", "hasEastArrowhead"], [True, False], (True, False)), ]) def test_compass_properties(region, mocker, method_name, value_names, mocked_values, expected_value): reg = region(RT.ANNCOMPASS) @@ -1088,7 +1093,7 @@ def test_set_arrowhead_visible(mocker, region, call_action, args, kwargs, expect @pytest.mark.parametrize("method_name,value_name,mocked_value,expected_value", [ - ("auxiliary_lines_visible", "auxiliaryLineVisible", True, True), + ("auxiliary_lines_visible", "isAuxiliaryLineVisible", True, True), ("auxiliary_lines_dash_length", "auxiliaryLineDashLength", 5, 5), ("text_offset", "textOffset", {"x": 1, "y": 2}, (1, 2)), ]) diff --git a/tests/test_vector_overlay.py b/tests/test_vector_overlay.py index 246993a..e7a3d52 100644 --- a/tests/test_vector_overlay.py +++ b/tests/test_vector_overlay.py @@ -2,7 +2,7 @@ from carta.vector_overlay import VectorOverlay from carta.util import Macro -from carta.constants import VectorOverlaySource as VOS, Auto, Colormap as CM +from carta.constants import VectorOverlaySource as VOS, Polarization as Pol, Auto, Colormap as CM # FIXTURES @@ -34,31 +34,28 @@ def image_call_action(image, mock_call_action): # Nothing ((), {}, None), # Everything - ((VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5), {}, (VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5)), - # Deduce pixel averaging flag + ((VOS.CURRENT, VOS.CURRENT, 1, 2, True, Pol.I, 3, True, 4, 5), {}, (VOS.CURRENT, VOS.CURRENT, 1, 2, True, 3, True, 4, 5, Pol.I)), + # Pixel averaging is passed directly to the frontend ((), {"pixel_averaging": 1}, - ("M(angularSource)", "M(intensitySource)", True, 1, "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", "M(debiasing)", "M(qError)", "M(uError)")), - # Don't deduce pixel averaging flag - ((), {"pixel_averaging": 1, "pixel_averaging_enabled": False}, - ("M(angularSource)", "M(intensitySource)", False, 1, "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", "M(debiasing)", "M(qError)", "M(uError)")), + ("M(angularSource)", "M(intensitySource)", 1, "M(isFractionalIntensity)", "M(isThresholdEnabled)", "M(threshold)", "M(isDebiasing)", "M(qError)", "M(uError)", "M(thresholdOption)")), # Deduce threshold flag ((), {"threshold": 2}, - ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", True, 2, "M(debiasing)", "M(qError)", "M(uError)")), + ("M(angularSource)", "M(intensitySource)", "M(pixelAveraging)", "M(isFractionalIntensity)", True, 2, "M(isDebiasing)", "M(qError)", "M(uError)", "M(thresholdOption)")), # Don't deduce threshold flag ((), {"threshold": 2, "threshold_enabled": False}, - ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", False, 2, "M(debiasing)", "M(qError)", "M(uError)")), + ("M(angularSource)", "M(intensitySource)", "M(pixelAveraging)", "M(isFractionalIntensity)", False, 2, "M(isDebiasing)", "M(qError)", "M(uError)", "M(thresholdOption)")), # Deduce debiasing flag ((), {"q_error": 3, "u_error": 4}, - ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", True, 3, 4)), + ("M(angularSource)", "M(intensitySource)", "M(pixelAveraging)", "M(isFractionalIntensity)", "M(isThresholdEnabled)", "M(threshold)", True, 3, 4, "M(thresholdOption)")), # Don't deduce debiasing flag ((), {"q_error": 3, "u_error": 4, "debiasing": False}, - ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", False, 3, 4)), + ("M(angularSource)", "M(intensitySource)", "M(pixelAveraging)", "M(isFractionalIntensity)", "M(isThresholdEnabled)", "M(threshold)", False, 3, 4, "M(thresholdOption)")), # Disable debiasing (no q_error) ((), {"u_error": 4, "debiasing": True}, - ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", False, "M(qError)", 4)), + ("M(angularSource)", "M(intensitySource)", "M(pixelAveraging)", "M(isFractionalIntensity)", "M(isThresholdEnabled)", "M(threshold)", False, "M(qError)", 4, "M(thresholdOption)")), # Disable debiasing (no u_error) ((), {"q_error": 3, "debiasing": True}, - ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", False, 3, "M(uError)")), + ("M(angularSource)", "M(intensitySource)", "M(pixelAveraging)", "M(isFractionalIntensity)", "M(isThresholdEnabled)", "M(threshold)", False, 3, "M(uError)", "M(thresholdOption)")), ]) def test_configure(vector_overlay, call_action, method, args, kwargs, expected_args): method("macro", lambda _, v: f"M({v})") @@ -142,8 +139,8 @@ def test_clear(vector_overlay, image_call_action): @pytest.mark.parametrize("args,kwargs,expected_calls", [ ([], {}, []), - ([VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5, 1, 2, 3, 4, 5, 6, "blue", CM.VIRIDIS, 0.5, 1.5], {}, [("configure", VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5), ("set_thickness", 1), ("set_intensity_range", 2, 3), ("set_length_range", 4, 5), ("set_rotation_offset", 6), ("set_color", "blue"), ("set_colormap", CM.VIRIDIS), ("set_bias_and_contrast", 0.5, 1.5), ("apply",)]), - ([], {"pixel_averaging": 1, "thickness": 2, "color": "blue", "bias": 0.5}, [("configure", None, None, None, 1, None, None, None, None, None, None), ("set_thickness", 2), ("set_color", "blue"), ("set_bias_and_contrast", 0.5, None), ("apply",)]), + ([VOS.CURRENT, VOS.CURRENT, 1, 2, True, Pol.I, 3, True, 4, 5, 1, 2, 3, 4, 5, 6, "blue", CM.VIRIDIS, 0.5, 1.5], {}, [("configure", VOS.CURRENT, VOS.CURRENT, 1, 2, True, Pol.I, 3, True, 4, 5), ("set_thickness", 1), ("set_intensity_range", 2, 3), ("set_length_range", 4, 5), ("set_rotation_offset", 6), ("set_color", "blue"), ("set_colormap", CM.VIRIDIS), ("set_bias_and_contrast", 0.5, 1.5), ("apply",)]), + ([], {"pixel_averaging": 1, "thickness": 2, "color": "blue", "bias": 0.5}, [("configure", None, None, 1, None, None, None, None, None, None, None), ("set_thickness", 2), ("set_color", "blue"), ("set_bias_and_contrast", 0.5, None), ("apply",)]), ([], {"thickness": 2}, [("set_thickness", 2), ("apply",)]), ]) def test_plot(vector_overlay, method, args, kwargs, expected_calls): diff --git a/tests/test_wcs_overlay.py b/tests/test_wcs_overlay.py index c7d1bd8..a7e80a4 100644 --- a/tests/test_wcs_overlay.py +++ b/tests/test_wcs_overlay.py @@ -158,7 +158,7 @@ def test_custom_color(overlay, component_get_value, comp_enum): comp_get_value = component_get_value(comp_enum, True) comp = overlay.get(comp_enum) custom_color = comp.custom_color - comp_get_value.assert_called_with("customColor") + comp_get_value.assert_called_with("hasCustomColor") assert custom_color is True @@ -175,7 +175,7 @@ def test_custom_text(overlay, component_get_value, comp_enum): comp_get_value = component_get_value(comp_enum, True) comp = overlay.get(comp_enum) custom_text = comp.custom_text - comp_get_value.assert_called_with("customText") + comp_get_value.assert_called_with("hasCustomText") assert custom_text is True @@ -258,7 +258,7 @@ def test_visible(overlay, component_get_value, comp_enum): comp = overlay.get(comp_enum) comp_get_value = component_get_value(comp_enum, True) visible = comp.visible - comp_get_value.assert_called_with("visible") + comp_get_value.assert_called_with("isVisible") assert visible is True @@ -353,7 +353,7 @@ def test_grid_gap(mocker, overlay, component_get_value): def test_grid_custom_gap(overlay, component_get_value): grid_get_value = component_get_value(O.GRID, True) custom_gap = overlay.grid.custom_gap - grid_get_value.assert_called_with("customGap") + grid_get_value.assert_called_with("hasCustomGap") assert custom_gap is True @@ -425,7 +425,7 @@ def test_numbers_custom_precision(overlay, component_get_value): numbers_get_value = component_get_value(O.NUMBERS) numbers_get_value.side_effect = [True] custom_precision = overlay.numbers.custom_precision - numbers_get_value.assert_called_with("customPrecision") + numbers_get_value.assert_called_with("hasCustomPrecision") assert custom_precision is True @@ -476,7 +476,7 @@ def test_ticks_density(mocker, overlay, component_get_value): def test_ticks_custom_density(overlay, component_get_value): ticks_get_value = component_get_value(O.TICKS, True) custom_density = overlay.ticks.custom_density - ticks_get_value.assert_called_with("customDensity") + ticks_get_value.assert_called_with("hasCustomDensity") assert custom_density is True @@ -489,7 +489,7 @@ def test_ticks_set_draw_on_all_edges(overlay, component_call_action): def test_ticks_draw_on_all_edges(overlay, component_get_value): ticks_get_value = component_get_value(O.TICKS, True) draw_on_all_edges = overlay.ticks.draw_on_all_edges - ticks_get_value.assert_called_with("drawAll") + ticks_get_value.assert_called_with("shouldDrawAll") assert draw_on_all_edges is True @@ -528,7 +528,7 @@ def test_colorbar_set_interactive(overlay, component_call_action): def test_colorbar_interactive(overlay, component_get_value): colorbar_get_value = component_get_value(O.COLORBAR, True) interactive = overlay.colorbar.interactive - colorbar_get_value.assert_called_with("interactive") + colorbar_get_value.assert_called_with("isInteractive") assert interactive is True @@ -585,10 +585,10 @@ def test_colorbar_get_border_properties(mocker, overlay, component_get_value): custom_color = overlay.colorbar.border.custom_color colorbar_get_value.assert_has_calls([ - mocker.call("borderVisible", return_path=None), + mocker.call("isBorderVisible", return_path=None), mocker.call("borderWidth", return_path=None), mocker.call("borderColor", return_path=None), - mocker.call("borderCustomColor", return_path=None), + mocker.call("hasBorderCustomColor", return_path=None), ]) assert visible is True @@ -630,10 +630,10 @@ def test_colorbar_get_ticks_properties(mocker, overlay, component_get_value): length = overlay.colorbar.ticks.length colorbar_get_value.assert_has_calls([ - mocker.call("tickVisible", return_path=None), + mocker.call("isTickVisible", return_path=None), mocker.call("tickWidth", return_path=None), mocker.call("tickColor", return_path=None), - mocker.call("tickCustomColor", return_path=None), + mocker.call("hasTickCustomColor", return_path=None), mocker.call("tickDensity", return_path=None), mocker.call("tickLen", return_path=None), ]) @@ -686,11 +686,11 @@ def test_colorbar_get_numbers_properties(mocker, overlay, component_get_value): rotation = overlay.colorbar.numbers.rotation colorbar_get_value.assert_has_calls([ - mocker.call("numberVisible", return_path=None), + mocker.call("isNumberVisible", return_path=None), mocker.call("numberPrecision", return_path=None), - mocker.call("numberCustomPrecision", return_path=None), + mocker.call("hasNumberCustomPrecision", return_path=None), mocker.call("numberColor", return_path=None), - mocker.call("numberCustomColor", return_path=None), + mocker.call("hasNumberCustomColor", return_path=None), mocker.call("numberFont", return_path=None), mocker.call("numberFontSize", return_path=None), mocker.call("numberRotation", return_path=None), @@ -743,10 +743,10 @@ def test_colorbar_get_label_properties(mocker, overlay, component_get_value): rotation = overlay.colorbar.label.rotation colorbar_get_value.assert_has_calls([ - mocker.call("labelVisible", return_path=None), + mocker.call("isLabelVisible", return_path=None), mocker.call("labelColor", return_path=None), - mocker.call("labelCustomColor", return_path=None), - mocker.call("labelCustomText", return_path=None), + mocker.call("hasLabelCustomColor", return_path=None), + mocker.call("hasLabelCustomText", return_path=None), mocker.call("labelFont", return_path=None), mocker.call("labelFontSize", return_path=None), mocker.call("labelRotation", return_path=None), @@ -775,7 +775,7 @@ def test_colorbar_get_gradient_properties(mocker, overlay, component_get_value): colorbar_get_value.side_effect = [True] visible = overlay.colorbar.gradient.visible colorbar_get_value.assert_has_calls([ - mocker.call("gradientVisible", return_path=None), + mocker.call("isGradientVisible", return_path=None), ]) assert visible is True