diff --git a/.github/workflows/frontend-api-check.yml b/.github/workflows/frontend-api-check.yml new file mode 100644 index 0000000..e124888 --- /dev/null +++ b/.github/workflows/frontend-api-check.yml @@ -0,0 +1,58 @@ +name: Frontend API 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 API checker + uses: actions/checkout@v6 + with: + repository: CARTAvis/carta-frontend + ref: zhenkai/api_check + path: carta-frontend + submodules: true + + - name: Install uv and set Python version + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.10" + enable-cache: true + + - name: Sync Python environment + run: uv sync --locked --no-group dev + + - name: Generate API manifest + run: uv run --no-sync scripts/extract_frontend_api.py --write-manifest + + - name: Install Node dependencies + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: carta-frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: carta-frontend + run: npm ci --ignore-scripts + + - name: Build frontend protobuf types + working-directory: carta-frontend + run: npm run build-protobuf + + - name: Check frontend APIs + working-directory: carta-frontend + run: npm run check-python-api -- --manifest "$GITHUB_WORKSPACE/frontend_api.json" 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/region.py b/carta/region.py index b64b2d0..009752e 100644 --- a/carta/region.py +++ b/carta/region.py @@ -1988,7 +1988,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 @@ -2109,7 +2109,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/session.py b/carta/session.py index 8b5f143..8d97c59 100644 --- a/carta/session.py +++ b/carta/session.py @@ -1011,10 +1011,8 @@ def rendered_view_url(self, background_color=None): """ self.call_action("waitForImageData") - args = ["getImageDataUrl"] - if background_color: - args.append(background_color) - return self.call_action(*args, response_expected=True) + args = [background_color] if background_color else [] + return self.call_action("getImageDataUrl", *args, response_expected=True) @validate(NoneOr(Color())) def rendered_view_data(self, background_color=None): diff --git a/carta/vector_overlay.py b/carta/vector_overlay.py index ff4bf6e..c965673 100644 --- a/carta/vector_overlay.py +++ b/carta/vector_overlay.py @@ -26,43 +26,37 @@ 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(), Boolean(), Boolean(), Number(), Boolean(), Number(), Number())) + def configure(self, angular_source=None, intensity_source=None, pixel_averaging=None, fractional_intensity=None, threshold_enabled=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``. - Parameters ---------- angular_source : {0} 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 : {6} + threshold : {5} The threshold in Jy/pixels. The initial value is zero. - debiasing : {7} + debiasing : {6} Enable debiasing. This is initially disabled. - q_error : {8} + q_error : {7} The Stokes Q error in Jy/beam. Set both this and ``u_error`` to enable debiasing. Initially set to zero. - u_error : {9} + u_error : {8} The Stokes U error in Jy/beam. Set both this and ``q_error`` to enable debiasing. Initially set to zero. """ # 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, 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,12 +71,11 @@ 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"), ): @@ -201,7 +194,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=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,47 +205,45 @@ 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 : {6} + threshold : {5} The threshold in Jy/pixels. The initial value is zero. - debiasing : {7} + debiasing : {6} Enable debiasing. This is initially disabled. - q_error : {8} + q_error : {7} The Stokes Q error in Jy/beam. Set both this and ``u_error`` to enable debiasing. Initially set to zero. - u_error : {9} + u_error : {8} The Stokes U error in Jy/beam. Set both this and ``q_error`` to enable debiasing. Initially set to zero. - thickness : {10} + thickness : {9} The line thickness in pixels. The initial value is ``1``. - intensity_min : {11} + intensity_min : {10} The minimum value of intensity in Jy/pixel. Use :obj:`carta.constants.Auto.AUTO` to clear the custom value and calculate it automatically. - intensity_max : {12} + intensity_max : {11} The maximum value of intensity in Jy/pixel. Use :obj:`carta.constants.Auto.AUTO` to clear the custom value and calculate it automatically. - length_min : {13} + length_min : {12} The minimum value of line length in pixels. The initial value is ``0``. - length_max : {14} + length_max : {13} The maximum value of line length in pixels. The initial value is ``20``. - rotation_offset : {15} + rotation_offset : {14} The rotation offset in degrees. The initial value is ``0``. - color : {16} + color : {15} The color. The initial value value is ``#238551`` (a shade of green). - colormap : {17} + colormap : {16} The colormap. The initial value is :obj:`carta.constants.Colormap.VIRIDIS`. - bias : {18} + bias : {17} The colormap bias. The initial value is ``0``. - contrast : {19} + contrast : {18} The colormap contrast. The initial value is ``1``. """ 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, 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 ba7cf34..44266d3 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): @@ -598,7 +598,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): @@ -692,7 +692,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): @@ -804,7 +804,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 +815,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): @@ -951,6 +951,15 @@ def get_value(self, path, return_path=None): object The unmodified return value of the colorbar method. """ + property_rewrites = { + "isVisible": f"is{self.PREFIX.title()}Visible", + "hasCustomColor": f"has{self.PREFIX.title()}CustomColor", + "hasCustomText": f"has{self.PREFIX.title()}CustomText", + "hasCustomPrecision": f"has{self.PREFIX.title()}CustomPrecision", + } + if path in property_rewrites: + return self.colorbar.get_value(property_rewrites[path], return_path=return_path) + def rewrite(m): before, first, rest = m.groups() return f"{before}{self.PREFIX}{first.upper()}{rest}" @@ -1129,7 +1138,7 @@ def interactive(self): boolean Whether the colorbar is interactive. """ - return self.get_value("interactive") + return self.get_value("isInteractive") @property def offset(self): diff --git a/scripts/extract_frontend_api.py b/scripts/extract_frontend_api.py new file mode 100644 index 0000000..a7af305 --- /dev/null +++ b/scripts/extract_frontend_api.py @@ -0,0 +1,626 @@ +#!/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 listed in ``DYNAMIC`` below. + +The extracted APIs are the two repositories' shared contract. CI generates +``frontend_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. +carta-frontend publishes the deprecated half of the contract, which ``--deprecations`` +checks this wrapper against. + +Usage: extract_frontend_api.py [--sites] [--json] [--check] + [--write-manifest] [--check-manifest] + [--deprecations FILE] [--report] +""" + +import argparse +import ast +import collections +import dataclasses +import difflib +import json +import pathlib +import re +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from carta.constants import RegionType # noqa: E402 +from carta.image import Image # noqa: E402 +from carta.region import Region # 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 temporary machine-readable contract generated for carta-frontend's CI. +MANIFEST = ROOT / "frontend_api.json" + +# The version of the manifest and deprecation list formats. +SCHEMA = 1 + +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.colorbar": "Colorbar", + "region_set": "RegionSet", + "self.region_set": "RegionSet", + "self.active_frame().regions": "RegionSet", +} + +# Call sites with a genuinely dynamic path, and the paths, relative to the +# object, which they may use. The wrapper passes a preference name provided by +# the user straight through to the frontend, so any preference may be read. +DYNAMIC = { + ("preferences.py", "Preferences", "get"): {"get_value": ["*"]}, +} + +ID_INDEX = re.compile(r"\[\d+\]") + + +@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 + + @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) + + 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) + + +class Scanner(ast.NodeVisitor): + """Collects the ``call_action`` and ``get_value`` call sites in one module.""" + + def __init__(self, module): + self.module = module + 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 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 = DYNAMIC.get((self.module, self.clazz, self.method), {}).get(node.func.attr) if not paths else None + 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 + + 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)) + + 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.""" + return [v.value for v in self.values(node) if isinstance(v, ast.Constant) and isinstance(v.value, str)] + + 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) + self.collect(self.session) + self.collect(image) + 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) + + +PYTHON_TO_FRONTEND_REGION_TYPES = { + "Region": "RegionStore", + "PointAnnotation": "PointAnnotationStore", + "TextAnnotation": "TextAnnotationStore", + "VectorAnnotation": "VectorAnnotationStore", + "CompassAnnotation": "CompassAnnotationStore", + "RulerAnnotation": "RulerAnnotationStore", +} + + +OVERLAY_RUNTIME_TYPES = { + "global": "OverlayGlobalSettings", + "title": "OverlayTitleSettings", + "grid": "OverlayGridSettings", + "border": "OverlayBorderSettings", + "axes": "OverlayAxisSettings", + "numbers": "OverlayNumberSettings", + "labels": "OverlayLabelSettings", + "ticks": "OverlayTickSettings", + "colorbar": "OverlayColorbarSettings", + "beam": "OverlayBeamSettings", +} + + +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}."): + runtime_type = PYTHON_TO_FRONTEND_REGION_TYPES.get(type(instance).__name__) + return [runtime_type] if runtime_type else sorted(set(PYTHON_TO_FRONTEND_REGION_TYPES.values())) + + 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."): + component = path.split(".")[1] + if component in OVERLAY_RUNTIME_TYPES: + return [OVERLAY_RUNTIME_TYPES[component]] + + 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.glob("*.py")): + scanner = Scanner(path.name) + scanner.visit(ast.parse(path.read_text())) + 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 the committed file changes only when the frontend API surface which the + wrapper uses changes, and not whenever an unrelated edit shifts a line. + """ + return { + "apis": [ + { + "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}), + } + for api in apis + ], + } + + +def dump_manifest(data): + """The canonical serialisation of the manifest.""" + return json.dumps(data, indent=2) + "\n" + + +def manifest_diff(data): + """The difference between the committed manifest and a regenerated manifest.""" + expected = dump_manifest(data) + actual = MANIFEST.read_text() if MANIFEST.exists() else "" + if actual == expected: + return "" + return "".join(difflib.unified_diff(actual.splitlines(True), expected.splitlines(True), "committed", "regenerated")) + + +def path_regex(path): + """A regular expression which matches the frontend paths a manifest path may use. + + ``[*]`` is a placeholder for an index or a map key, and is matched literally, + because both sides of the contract normalise indices to it. Any other ``*`` + is a wildcard for a single path component, which is how the extraction + represents a path which the wrapper interpolates. + """ + components = [re.escape(component).replace("\\*", "[^.]*") for component in path.split("[*]")] + return re.compile(f"^{re.escape('[*]').join(components)}$") + + +def deprecated_apis(api, deprecations): + """The deprecated frontend APIs which a manifest entry may use. + + Either side of the contract may be a glob, so both directions are matched. + """ + matches = path_regex(api["path"]) + return [d for d in deprecations if matches.match(d["path"]) or path_regex(d["path"]).match(api["path"])] + + +def load_deprecations(path): + """The deprecated frontend APIs published by carta-frontend.""" + data = json.loads(pathlib.Path(path).read_text()) + if data.get("schema") != SCHEMA: + raise SystemExit(f"Cannot read {path}: expected schema {SCHEMA}, found {data.get('schema')!r}.") + return data + + +def report_deprecations(apis, data): + """Print the frontend APIs which the wrapper uses and carta-frontend has deprecated. + + Returns the number of deprecated frontend APIs which the wrapper uses. + """ + deprecations = data["deprecations"] + found = [(api, d) for api in apis for d in deprecated_apis(api, deprecations)] + + version = data.get("frontend_version", "unknown") + print(f"\nDEPRECATED FRONTEND APIS IN USE ({len(found)})\n") + print(f" checked {len(apis)} frontend APIs against {len(deprecations)} deprecations from carta-frontend {version}\n") + + for api, deprecation in found: + replacement = deprecation.get("replacement") or deprecation.get("message") or "no replacement documented" + print(f" {api['kind']} {api['path']}\n deprecated: {replacement}\n used by: {', '.join(api['wrappers'])}") + + return len(found) + + +def print_json(apis, unresolved): + """Print every frontend API, its call sites and any unresolved call sites, as JSON.""" + print(json.dumps({ + "apis": [ + { + "kind": api.kind, + "path": api.path, + "exact": api.exact, + "return_path": api.return_path, + "runtime_types": sorted(api.runtime_types), + "sites": [s.location for s in api.sites], + "wrappers": sorted({s.qualname for s in api.sites}), + } + for api in apis + ], + "unresolved": [{"location": location, "method": f"{clazz}.{method}", "source": source} for location, clazz, method, source in unresolved], + }, indent=2)) + + +def print_report(apis, unresolved, sites, objects, show_sites): + """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 "" + print(f" {api.path}{suffix}{'' if api.exact else ' [glob]'}") + if show_sites: + for site in api.sites: + print(f" {site.location} {site.qualname}") + + 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("--sites", action="store_true", help="list the wrapper call sites of each frontend API") + parser.add_argument("--json", action="store_true", help="output JSON instead of text") + 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("--check-manifest", action="store_true", help="exit with an error if the contract manifest is out of date") + parser.add_argument("--deprecations", metavar="FILE", help="exit with an error if the wrapper uses a frontend API deprecated in FILE, the deprecation list published by carta-frontend") + parser.add_argument("--report", action="store_true", help="print all findings, but exit successfully") + args = parser.parse_args() + + 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: + MANIFEST.write_text(dump_manifest(data)) + print(f"Wrote {len(data['apis'])} frontend APIs to {MANIFEST}.") + + if args.check_manifest: + diff = manifest_diff(data) + if diff: + print(f"{MANIFEST} is out of date. Regenerate it with:\n\n uv run scripts/extract_frontend_api.py --write-manifest\n\n{diff}") + failed = True + else: + print(f"{MANIFEST} is up to date ({len(data['apis'])} frontend APIs).") + + if args.deprecations: + failed |= bool(report_deprecations(data["apis"], load_deprecations(args.deprecations))) + + if not (args.write_manifest or args.check_manifest or args.deprecations): + if args.json: + print_json(ordered, unresolved) + else: + print_report(ordered, unresolved, sites, len(registry.objects), args.sites) + + return 1 if failed and not args.report else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_extract_frontend_api.py b/tests/test_extract_frontend_api.py new file mode 100644 index 0000000..9e55813 --- /dev/null +++ b/tests/test_extract_frontend_api.py @@ -0,0 +1,109 @@ +import importlib.util +import json +import pathlib + +import pytest + +SCRIPT = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "extract_frontend_api.py" + + +def load_script(): + spec = importlib.util.spec_from_file_location("extract_frontend_api", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +extract = load_script() + + +@pytest.fixture(scope="module") +def manifest(): + sites, _ = extract.scan() + apis = extract.replay(extract.Registry(), sites) + return extract.manifest(sorted(apis.values(), key=lambda a: (a.kind, a.path))) + + +def deprecations(*paths): + return [{"path": path, "kind": "action", "owner": "Store", "member": path.rpartition(".")[2], "message": "gone"} for path in paths] + + +def api(path, exact=True): + return {"kind": "action", "path": path, "exact": exact, "return_path": "", "runtime_types": [], "wrappers": ["Wrapper.method"]} + + +def test_manifest_has_only_api_entries(manifest): + assert set(manifest) == {"apis"} + + +def test_manifest_entries_are_sorted_and_stable(manifest): + entries = manifest["apis"] + assert entries + assert [(e["kind"], e["path"]) for e in entries] == sorted((e["kind"], e["path"]) for e in entries) + for entry in entries: + # Source locations are excluded, so that unrelated edits do not change the contract. + assert set(entry) == {"kind", "path", "exact", "return_path", "runtime_types", "wrappers"} + assert entry["kind"] in ("action", "parameter", "reference") + assert entry["runtime_types"] == sorted(entry["runtime_types"]) + assert entry["wrappers"] == sorted(entry["wrappers"]) + + +def test_manifest_serialisation_is_canonical(manifest): + dumped = extract.dump_manifest(manifest) + assert dumped.endswith("\n") + assert json.loads(dumped) == manifest + + +def test_manifest_diff_reports_a_change(manifest): + changed = {"apis": [dict(manifest["apis"][0], path="changed")]} + diff = extract.manifest_diff(changed) + assert "changed" in diff + + +def test_exact_path_is_deprecated(): + assert extract.deprecated_apis(api("frameMap[*].renderConfig.setColorMap"), deprecations("frameMap[*].renderConfig.setColorMap")) + + +def test_unrelated_path_is_not_deprecated(): + assert not extract.deprecated_apis(api("frameMap[*].renderConfig.setColorMap"), deprecations("frameMap[*].renderConfig.setGamma")) + + +def test_wrapper_glob_matches_deprecated_path(): + assert extract.deprecated_apis(api("frameMap[*].zoomToSize*", exact=False), deprecations("frameMap[*].zoomToSizeXWcs")) + + +def test_frontend_glob_matches_wrapper_path(): + assert extract.deprecated_apis(api("overlaySettings.colorbar.setLabelFont"), deprecations("overlaySettings.colorbar.setLabel*")) + + +def test_wildcard_matches_a_single_component_only(): + assert extract.deprecated_apis(api("preferenceStore.*", exact=False), deprecations("preferenceStore.astGridVisible")) + assert not extract.deprecated_apis(api("preferenceStore.*", exact=False), deprecations("preferenceStore.nested.value")) + + +def test_index_placeholder_is_not_a_wildcard(): + assert not extract.deprecated_apis(api("frameMap[*].setZoom"), deprecations("frameMapEntry.setZoom")) + assert not extract.deprecated_apis(api("frameMap[*].setZoom"), deprecations("frames[*].setZoom")) + assert extract.deprecated_apis(api("frameMap[*].regionSet.regionMap[*].setColor"), deprecations("frameMap[*].regionSet.regionMap[*].setColor")) + + +def test_report_deprecations_counts_matches(capsys): + data = {"schema": 1, "frontend_version": "6.1.0-dev", "deprecations": deprecations("a.b", "c.d")} + assert extract.report_deprecations([api("a.b"), api("e.f")], data) == 1 + output = capsys.readouterr().out + assert "a.b" in output + assert "Wrapper.method" in output + assert "6.1.0-dev" in output + + +def test_load_deprecations(tmp_path): + path = tmp_path / "deprecations.json" + path.write_text(json.dumps({"schema": extract.SCHEMA, "frontend_version": "6.1.0-dev", "deprecations": []})) + assert extract.load_deprecations(path)["deprecations"] == [] + + +def test_load_deprecations_rejects_an_unknown_schema(tmp_path): + path = tmp_path / "deprecations.json" + path.write_text(json.dumps({"schema": extract.SCHEMA + 1, "deprecations": []})) + with pytest.raises(SystemExit): + extract.load_deprecations(path) 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 38e0820..e9e59f9 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -1014,7 +1014,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) @@ -1084,7 +1084,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_session.py b/tests/test_session.py index f133e0a..0a79575 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -947,3 +947,19 @@ def test_open_hypercube_bad(mocker, session, call_action, method, paths, expecte with pytest.raises(Exception) as e: session.open_hypercube(paths, append) assert expected_error in str(e.value) + + +@pytest.mark.parametrize("background_color,expected_args", [ + (None, ()), + ("white", ("white",)), +]) +def test_rendered_view_url(mocker, session, call_action, background_color, expected_args): + call_action.side_effect = [None, "data:image/png;base64,AAAA"] + + url = session.rendered_view_url(background_color) + + call_action.assert_has_calls([ + mocker.call("waitForImageData"), + mocker.call("getImageDataUrl", *expected_args, response_expected=True), + ]) + assert url == "data:image/png;base64,AAAA" diff --git a/tests/test_vector_overlay.py b/tests/test_vector_overlay.py index 246993a..b78defd 100644 --- a/tests/test_vector_overlay.py +++ b/tests/test_vector_overlay.py @@ -34,31 +34,25 @@ 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 - ((), {"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)")), + ((VOS.CURRENT, VOS.CURRENT, 1, True, True, 3, True, 4, 5), {}, (VOS.CURRENT, VOS.CURRENT, 1, True, True, 3, True, 4, 5)), # 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)")), # 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)")), # 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)), # 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)), # 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)), # 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)")), ]) def test_configure(vector_overlay, call_action, method, args, kwargs, expected_args): method("macro", lambda _, v: f"M({v})") @@ -142,8 +136,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, True, True, 3, True, 4, 5, 1, 2, 3, 4, 5, 6, "blue", CM.VIRIDIS, 0.5, 1.5], {}, [("configure", VOS.CURRENT, VOS.CURRENT, 1, True, 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, 1, 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