From 2e964716fe4f308e9900e9fe0c4950072d002950 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 18 Aug 2026 20:51:37 +0800 Subject: [PATCH 1/5] Add GitHub Actions workflow for frontend API compatibility checking --- .github/workflows/frontend-api-check.yml | 56 ++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/frontend-api-check.yml diff --git a/.github/workflows/frontend-api-check.yml b/.github/workflows/frontend-api-check.yml new file mode 100644 index 0000000..cc30ab1 --- /dev/null +++ b/.github/workflows/frontend-api-check.yml @@ -0,0 +1,56 @@ +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 + + - 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: Check generated manifest is committed + run: git diff --exit-code -- frontend_api.json + + - 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: Check frontend APIs + working-directory: carta-frontend + run: npm run check-python-api -- --manifest "$GITHUB_WORKSPACE/frontend_api.json" From 73dd8d7372784d5d3727e2001c06ecbdc0ff77ca Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 18 Aug 2026 20:54:26 +0800 Subject: [PATCH 2/5] Add script for extracting and validating frontend API usage --- scripts/extract_frontend_api.py | 626 ++++++++++++++++++++++++++++++++ tests/test_frontend_api.py | 117 ++++++ 2 files changed, 743 insertions(+) create mode 100644 scripts/extract_frontend_api.py create mode 100644 tests/test_frontend_api.py diff --git a/scripts/extract_frontend_api.py b/scripts/extract_frontend_api.py new file mode 100644 index 0000000..62a58a3 --- /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 also the two repositories' shared contract. ``frontend_api.json`` +in the root of this repository is the machine-readable form of the contract, which +carta-frontend's CI fetches to check 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 machine-readable contract, consumed by 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_frontend_api.py b/tests/test_frontend_api.py new file mode 100644 index 0000000..6caf2b0 --- /dev/null +++ b/tests/test_frontend_api.py @@ -0,0 +1,117 @@ +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_is_current(manifest): + assert extract.manifest_diff(manifest) == "", "frontend_api.json is out of date: run scripts/extract_frontend_api.py --write-manifest" + + +def test_manifest_matches_committed_file(manifest): + assert json.loads(extract.MANIFEST.read_text()) == manifest + + +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) From 5b37c138fad0772af028098bf1ada5f5c20f75dd Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 18 Aug 2026 21:03:29 +0800 Subject: [PATCH 3/5] Remove frontend_api.json commit check and update documentation to reflect temporary manifest generation --- .github/workflows/frontend-api-check.yml | 3 --- scripts/extract_frontend_api.py | 8 ++++---- ...{test_frontend_api.py => test_extract_frontend_api.py} | 8 -------- 3 files changed, 4 insertions(+), 15 deletions(-) rename tests/{test_frontend_api.py => test_extract_frontend_api.py} (93%) diff --git a/.github/workflows/frontend-api-check.yml b/.github/workflows/frontend-api-check.yml index cc30ab1..29be022 100644 --- a/.github/workflows/frontend-api-check.yml +++ b/.github/workflows/frontend-api-check.yml @@ -37,9 +37,6 @@ jobs: - name: Generate API manifest run: uv run --no-sync scripts/extract_frontend_api.py --write-manifest - - name: Check generated manifest is committed - run: git diff --exit-code -- frontend_api.json - - name: Install Node dependencies uses: actions/setup-node@v6 with: diff --git a/scripts/extract_frontend_api.py b/scripts/extract_frontend_api.py index 62a58a3..a7af305 100644 --- a/scripts/extract_frontend_api.py +++ b/scripts/extract_frontend_api.py @@ -20,9 +20,9 @@ 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 also the two repositories' shared contract. ``frontend_api.json`` -in the root of this repository is the machine-readable form of the contract, which -carta-frontend's CI fetches to check that every frontend API used here still exists. +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`` @@ -55,7 +55,7 @@ PACKAGE = ROOT / "carta" -# The machine-readable contract, consumed by carta-frontend's CI. +# 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. diff --git a/tests/test_frontend_api.py b/tests/test_extract_frontend_api.py similarity index 93% rename from tests/test_frontend_api.py rename to tests/test_extract_frontend_api.py index 6caf2b0..9e55813 100644 --- a/tests/test_frontend_api.py +++ b/tests/test_extract_frontend_api.py @@ -32,14 +32,6 @@ def api(path, exact=True): return {"kind": "action", "path": path, "exact": exact, "return_path": "", "runtime_types": [], "wrappers": ["Wrapper.method"]} -def test_manifest_is_current(manifest): - assert extract.manifest_diff(manifest) == "", "frontend_api.json is out of date: run scripts/extract_frontend_api.py --write-manifest" - - -def test_manifest_matches_committed_file(manifest): - assert json.loads(extract.MANIFEST.read_text()) == manifest - - def test_manifest_has_only_api_entries(manifest): assert set(manifest) == {"apis"} From de99b257135f7f9ad425df67401fbc0af9c54872 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 18 Aug 2026 21:07:50 +0800 Subject: [PATCH 4/5] Update frontend property names --- carta/image.py | 2 +- carta/region.py | 4 +- carta/session.py | 6 +-- carta/vector_overlay.py | 73 ++++++++++++++++-------------------- carta/wcs_overlay.py | 29 +++++++++----- tests/test_image.py | 2 +- tests/test_region.py | 4 +- tests/test_session.py | 16 ++++++++ tests/test_vector_overlay.py | 24 +++++------- tests/test_wcs_overlay.py | 38 +++++++++---------- 10 files changed, 103 insertions(+), 95 deletions(-) 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/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 From 31aa69ed41cbb1d796d86c6fa2bdb520f91f6719 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 18 Aug 2026 21:27:48 +0800 Subject: [PATCH 5/5] Add submodule checkout and protobuf build steps to frontend API check workflow --- .github/workflows/frontend-api-check.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/frontend-api-check.yml b/.github/workflows/frontend-api-check.yml index 29be022..e124888 100644 --- a/.github/workflows/frontend-api-check.yml +++ b/.github/workflows/frontend-api-check.yml @@ -24,6 +24,7 @@ jobs: 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 @@ -48,6 +49,10 @@ jobs: 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"