diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..4ef676c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,32 @@ +### Description + +Linked issues and companion PRs (if any), what is implemented or fixed, and +how to test it. + +### Checklist + +For linked issues (if any): + +- [ ] assignee and labels added +- [ ] GitHub Project estimate added + +For the pull request: + +- [ ] reviewers and assignee added +- [ ] GitHub Project estimate added +- [ ] tests added or updated / no tests needed +- [ ] documentation updated / no documentation update needed + +For dependencies and compatibility: + +- [ ] companion frontend or backend PRs linked / no companion PRs needed +- [ ] `pyproject.toml` and `uv.lock` updated / no dependency changes +- [ ] CARTA compatibility table updated when the minimum CARTA series changes + or `dev` starts a new `carta-python` major/minor series / no compatibility + change + +For pull requests targeting `main`: + +- [ ] `VERSION.txt` finalized for the release +- [ ] final compatibility entry matches the release major/minor version and + minimum supported CARTA series diff --git a/.gitignore b/.gitignore index 8aabb01..ffb1064 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__ build dist carta.egg-info +carta_python.egg-info .DS_Store .coverage .coverage.* diff --git a/README.md b/README.md index db279f4..5a5dd7a 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,13 @@ carta-python This is a prototype of a scripting interface which uses a generic HTTP interface in the CARTA backend as a proxy to call actions on the CARTA frontend. -This package is not yet published on PyPi, but can be installed from the local repository directory with `pip`. Requires Python 3.10 or later. Ensure that you're using the corresponding `pip`, either using a `virtualenv` or the appropriate system executable, which may be called `pip3`. Required dependencies should be installed automatically: +Install the latest release from PyPI. Requires Python 3.10 or later. Required dependencies are installed automatically: - pip install . + pip install --upgrade carta-python -To create a new frontend session which is controlled by the wrapper instead of connecting to an existing frontend session, you also need to install the `selenium` Python library: +To create a new frontend session which is controlled by the wrapper instead of connecting to an existing frontend session, install the optional browser dependencies: - pip install selenium - -If you prefer installing the optional browser dependencies via package extras, the equivalent command is: - - pip install ".[browser]" + pip install --upgrade "carta-python[browser]" You also need to make sure that your desired browser is installed, together with a corresponding web driver. @@ -53,7 +49,7 @@ Alternative contributor setup with uv If you prefer a synced local development environment, `uv` is also supported: ``` -uv sync +uv sync --all-extras ``` This creates a local virtual environment, installs the package in editable mode, and syncs the default development groups. diff --git a/VERSION.txt b/VERSION.txt index 26aaba0..d72f262 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.0 +2.0.0-dev diff --git a/carta/browser.py b/carta/browser.py index 4ae5d89..91bffb8 100644 --- a/carta/browser.py +++ b/carta/browser.py @@ -2,17 +2,46 @@ import time -from selenium import webdriver -from selenium.webdriver.chrome.options import Options -from selenium.webdriver.common.by import By -from selenium.common.exceptions import NoSuchElementException +try: + from selenium import webdriver + from selenium.webdriver.chrome.options import Options + from selenium.webdriver.common.by import By + from selenium.common.exceptions import NoSuchElementException +except ModuleNotFoundError as error: + if error.name != "selenium": + raise + webdriver = None + Options = None + By = None + NoSuchElementException = None + _SELENIUM_IMPORT_ERROR = error +else: + _SELENIUM_IMPORT_ERROR = None from .backend import Backend -from .util import CartaBadSession +from .constants import VersionMismatchAction +from .util import CartaBadSession, logger from .protocol import Protocol from .session import Session +def _require_selenium(): + if _SELENIUM_IMPORT_ERROR is not None: + raise CartaBadSession( + "Browser automation requires the optional Selenium dependency.\n\n" + "Install it with pip:\n" + " python -m pip install --upgrade \"carta-python[browser]\"\n\n" + "For a uv-managed script:\n" + " uv add --script your_script.py \"carta-python[browser]\"\n" + " uv run your_script.py\n\n" + "If your script already pins carta-python, include the same " + "version constraint when adding the browser extra, for example:\n" + " uv add --script your_script.py " + "\"carta-python[browser]~=2.0.0\" --upgrade-package carta-python\n" + " uv run your_script.py" + ) from _SELENIUM_IMPORT_ERROR + + class Browser: """The top-level browser class. @@ -34,7 +63,7 @@ class Browser: def __init__(self, driver_class, **kwargs): self.driver = driver_class(**kwargs) - def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=10, debug_no_auth=False): + def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=10, debug_no_auth=False, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Create a new session by connecting to an existing backend. You can use :obj:`carta.session.Session.create`, which wraps this method. @@ -51,6 +80,12 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 The number of seconds to spend parsing the frontend for connection information. 10 seconds by default. debug_no_auth : boolean Disable authentication. This should be set if the backend has been started with the ``--debug_no_auth`` option. This is provided for debugging purposes only and should not be used under normal circumstances. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -65,8 +100,13 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 If an invalid URL was provided. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ + _require_selenium() protocol = Protocol(frontend_url, token, debug_no_auth=debug_no_auth) if protocol.controller_auth: @@ -97,9 +137,15 @@ def new_session_from_url(self, frontend_url, token=None, backend=None, timeout=1 if not session_id: self.exit(f"Could not parse session ID from frontend. Last error: {last_error}") - return Session(session_id, protocol, browser=self, backend=backend) + session = Session(session_id, protocol, browser=self, backend=backend) + try: + session._validate_session(timeout=connection_check_timeout, version_mismatch_action=version_mismatch_action) + except Exception: + self._close_after_error() + raise + return session - def new_session_with_backend(self, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10): + def new_session_with_backend(self, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Create a new session after launching a new backend process. You can use :obj:`carta.session.Session.start_and_create`, which wraps this method. This method starts a backend process, parses the frontend URL from the output, and calls :obj:`carta.browser.Browser.new_session_from_url`. @@ -118,6 +164,12 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa The security token to use. Parsed from the backend output by default. frontend_url_timeout : integer How long to keep checking the backend output for the frontend URL. Default: 10 seconds. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -132,6 +184,10 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa If an invalid URL was provided. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ backend = Backend(("--no_browser", "--enable_scripting", *params), executable_path, remote_host, token, frontend_url_timeout=frontend_url_timeout, session_creation_timeout=0) @@ -139,19 +195,41 @@ def new_session_with_backend(self, executable_path="carta", remote_host=None, pa self.exit(f"CARTA backend exited unexpectedly:\n{''.join(backend.errors)}") if backend.frontend_url is None: + backend.stop() self.exit("Could not parse CARTA frontend URL from backend output.") - return self.new_session_from_url(backend.frontend_url, backend.token, backend=backend, timeout=timeout, debug_no_auth=backend.debug_no_auth) + try: + return self.new_session_from_url( + backend.frontend_url, + backend.token, + backend=backend, + timeout=timeout, + debug_no_auth=backend.debug_no_auth, + connection_check_timeout=connection_check_timeout, + version_mismatch_action=version_mismatch_action, + ) + except Exception: + try: + backend.stop() + except Exception: + logger.exception("Failed to stop CARTA backend after session creation failed.") + raise def exit(self, msg): """Exit the browser with an error.""" - self.close() + self._close_after_error() raise CartaBadSession(msg) def close(self): """Shut down the browser driver.""" self.driver.quit() + def _close_after_error(self): + try: + self.close() + except Exception: + pass + class Chrome(Browser): """Chrome or Chromium, optionally headless. @@ -169,6 +247,7 @@ class Chrome(Browser): """ def __init__(self, headless=True, browser_path=None, driver_path=None, options=tuple()): + _require_selenium() chrome_options = Options() if headless: chrome_options.add_argument("--headless") diff --git a/carta/compatibility.py b/carta/compatibility.py new file mode 100644 index 0000000..034d7d8 --- /dev/null +++ b/carta/compatibility.py @@ -0,0 +1,19 @@ +"""Compatibility data for CARTA and carta-python releases.""" + + +COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": None, "wrapper": "2.0"}, +) +"""The recommended compatibility between CARTA and carta-python versions. + +Each entry contains the oldest and newest CARTA series covered by the range and +the latest compatible carta-python series. ``carta_max=None`` means that the +range remains compatible with all later CARTA series until a known breaking +change requires an explicit upper bound and a new entry. + +Ranges must be listed from oldest to newest and must not overlap. Only the final +entry may omit ``carta_max``. The ``wrapper`` value in the final entry must +match the major and minor version in ``VERSION.txt``. Its ``carta_min`` value +defines the minimum CARTA version for which the current carta-python release +provides complete functionality. +""" diff --git a/carta/constants.py b/carta/constants.py index 87dc673..7f97146 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -37,6 +37,12 @@ class ImageType(IntEnum): PV_PREVIEW = 2 +class VersionMismatchAction(StrEnum): + """Action to take when a CARTA version check does not pass.""" + WARN = "warn" + ERROR = "error" + + Scaling = IntEnum('Scaling', ('LINEAR', 'LOG', 'SQRT', 'SQUARE', 'POWER', 'GAMMA'), start=0) Scaling.__doc__ = """Colormap scaling types.""" diff --git a/carta/error_messages.py b/carta/error_messages.py new file mode 100644 index 0000000..e83aae2 --- /dev/null +++ b/carta/error_messages.py @@ -0,0 +1,116 @@ +"""Formatting helpers for user-facing CARTA error messages.""" + +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import requests + + +_STATUS_SUGGESTIONS = { + 400: "Check the scripting request parameters.", + 403: "Check the CARTA token and authentication settings.", + 404: "Verify the session ID and confirm that the session is still active.", + 500: "Check the CARTA backend logs for the underlying error.", + 501: "Restart the CARTA backend with the `--enable_scripting` option.", +} + + +def format_frontend_version_unavailable_error(current, include_warn_suggestion): + """Format the error raised when the frontend version cannot be retrieved.""" + suggestions = [ + f"Upgrade CARTA to at least {current.carta_minimum_version!r}.", + "If CARTA is already '6.0.0' or newer, wait until the frontend is fully loaded and retry.", + ] + if include_warn_suggestion: + suggestions.append( + "If this combination is known to work, set `version_mismatch_action=VersionMismatchAction.WARN`." + ) + + return ( + "CARTA version validation failed:\n" + "- Could not retrieve `frontendVersion` from the CARTA frontend.\n" + "- CARTA versions earlier than '6.0.0' do not expose `frontendVersion`.\n" + f"- carta-python {current.wrapper_label} requires CARTA " + f"{current.carta_minimum_version!r} or later.\n\n" + "Suggested actions:\n" + + "\n".join(f"- {suggestion}" for suggestion in suggestions) + ) + + +def format_version_mismatch_error(mismatches, suggestions, include_warn_suggestion): + """Format a CARTA/carta-python version compatibility error.""" + suggestions = list(suggestions) + if include_warn_suggestion: + suggestions.append( + "If this combination is known to work, set `version_mismatch_action=VersionMismatchAction.WARN`." + ) + + message = "CARTA version validation failed:\n" + "\n".join( + f"- {mismatch}" for mismatch in mismatches + ) + if suggestions: + message += "\n\nSuggested actions:\n" + "\n".join( + f"- {suggestion}" for suggestion in suggestions + ) + return message + + +def format_session_validation_error(session_id, uri, timeout, error): + """Format the error raised when a CARTA session cannot be validated.""" + safe_uri = _redact_url(uri) + + cause = error + while cause.__cause__ is not None: + cause = cause.__cause__ + + if isinstance(cause, requests.exceptions.Timeout): + message = ( + f"Could not validate CARTA session {session_id}: " + f"request timed out after {timeout} seconds.\n" + f"Endpoint: {safe_uri!r}\n" + f"Timeout: {timeout} seconds\n" + "Action: fetchParameter(frontendVersion)\n" + "The CARTA scripting request did not receive a response before the timeout.\n\n" + "Possible causes:\n" + "- The CARTA backend/frontend is not reachable.\n" + "- The frontend has not finished loading.\n" + "- The session ID is wrong or the session has closed.\n\n" + "Suggested actions:\n" + f"- Retry with a longer `connection_check_timeout` than {timeout} seconds.\n" + "- Verify that the CARTA session is still active.\n" + ) + elif getattr(error, "status_code", None) is not None: + status_code = error.status_code + backend_message = getattr(error, "backend_message", str(error)) + message = ( + f"Could not validate CARTA session {session_id}: " + f"the CARTA backend returned HTTP status {status_code}.\n" + f"Endpoint: {safe_uri!r}\n" + "Action: fetchParameter(frontendVersion)\n" + f"Reason: {backend_message}\n" + ) + suggested_action = _STATUS_SUGGESTIONS.get(status_code) + if suggested_action: + message += f"\nSuggested action:\n- {suggested_action}" + else: + message = ( + f"Could not validate CARTA session {session_id}.\n" + f"Endpoint: {safe_uri!r}\n" + f"Timeout setting: {timeout} seconds\n" + "Action: fetchParameter(frontendVersion)\n\n" + f"Original error ({error.__class__.__name__}): {error}" + ) + + return message.rstrip() + + +def _redact_url(uri): + """Remove authentication tokens from URLs included in error messages.""" + if uri is None: + return None + + parsed = urlsplit(uri) + query = [ + (key, "" if key.lower() in {"token", "access_token"} else value) + for key, value in parse_qsl(parsed.query, keep_blank_values=True) + ] + return urlunsplit(parsed._replace(query=urlencode(query))) diff --git a/carta/protocol.py b/carta/protocol.py index 8bb0dad..9cb644a 100644 --- a/carta/protocol.py +++ b/carta/protocol.py @@ -8,7 +8,7 @@ import json from .token import BackendToken, ControllerToken -from .util import logger, CartaBadRequest, CartaRequestFailed, CartaActionFailed, CartaBadResponse, CartaBadToken, CartaBadUrl, CartaEncoder, split_action_path +from .util import logger, CartaBadRequest, CartaRequestFailed, CartaActionFailed, CartaBadResponse, CartaMissingResponse, CartaBadToken, CartaBadUrl, CartaEncoder, split_action_path class AuthType: @@ -293,7 +293,8 @@ def request_scripting_action(self, session_id, path, *args, **kwargs): request_data = json.dumps(request_kwargs, cls=CartaEncoder) - carta_action_description = f"CARTA scripting action {path}.{action} called with parameters {args}" + action_path = f"{path}.{action}" if path else action + carta_action_description = f"CARTA scripting action {action_path} called with parameters {args}" headers = { 'Content-Type': 'application/json', @@ -322,7 +323,12 @@ def request_scripting_action(self, session_id, path, *args, **kwargs): if response.status_code != 200: backend_message = known_errors.get(response.status_code, "Unknown error.") - raise CartaRequestFailed(f"{carta_action_description} failed with status {response.status_code}. {backend_message}") + error = CartaRequestFailed( + f"{carta_action_description} failed with status {response.status_code}. {backend_message}" + ) + error.status_code = response.status_code + error.backend_message = backend_message + raise error try: response_data = response.json() @@ -336,7 +342,7 @@ def request_scripting_action(self, session_id, path, *args, **kwargs): if 'response' not in response_data: if response_expected: - raise CartaBadResponse(f"{carta_action_description} expected a response, but did not receive one.") + raise CartaMissingResponse(f"{carta_action_description} expected a response, but did not receive one.") return None return response_data['response'] diff --git a/carta/session.py b/carta/session.py index a757fd4..11b98bc 100644 --- a/carta/session.py +++ b/carta/session.py @@ -12,11 +12,45 @@ from .image import Image from .view import View from .color_blending import ColorBlending -from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet +from .constants import ( + PanelMode, + GridMode, + ComplexComponent, + ImageType, + Polarization, + ColormapSet, + VersionMismatchAction, +) from .backend import Backend +from .error_messages import ( + format_frontend_version_unavailable_error, + format_session_validation_error, + format_version_mismatch_error, +) from .protocol import Protocol -from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, deprecated, logger, Point as Pt +from .util import ( + Macro, + split_action_path, + CartaActionFailed, + CartaBadResponse, + CartaMissingResponse, + CartaBadID, + CartaBadSession, + CartaBadUrl, + CartaScriptingException, + CartaValidationFailed, + CartaUnsupportedVersion, + cached, + deprecated, + logger, + Point as Pt, +) from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, InstanceOf, MapOf, Union +from .version import ( + action_failure_compatibility_suggestions, + latest_compatibility, + version_mismatch_details, +) from .wcs_overlay import SessionWCSOverlay from .raster import SessionRaster @@ -76,7 +110,7 @@ def __del__(self): self.close() @classmethod - def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, backend=None): + def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, backend=None, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Interact with an existing CARTA frontend session. Parameters @@ -91,6 +125,12 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac Disable authentication. Set this if the backend has been started with the ``--debug_no_auth`` option. This is provided for debugging purposes only and should not be used under normal circumstances. backend : :obj:`carta.backend.Backend` The backend object associated with this session, if any. This is set if this method is called from :obj:`carta.session.Session.start_and_interact`. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -107,16 +147,22 @@ def interact(cls, frontend_url, session_id, token=None, debug_no_auth=False, bac If an invalid URL was provided. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ try: session_id = int(session_id) except ValueError: raise CartaBadID(f"Session ID '{session_id}' is not a number.") - return cls(session_id, Protocol(frontend_url, token, debug_no_auth=debug_no_auth), backend=backend) + session = cls(session_id, Protocol(frontend_url, token, debug_no_auth=debug_no_auth), backend=backend) + session._validate_session(timeout=connection_check_timeout, version_mismatch_action=version_mismatch_action) + return session @classmethod - def start_and_interact(cls, executable_path="carta", remote_host=None, params=tuple(), token=None, frontend_url_timeout=10, session_creation_timeout=10): + def start_and_interact(cls, executable_path="carta", remote_host=None, params=tuple(), token=None, frontend_url_timeout=10, session_creation_timeout=10, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Start a new CARTA backend instance and interact with the default CARTA frontend session which is created automatically in the user's browser. This method cannot be used with a CARTA controller instance. Parameters @@ -133,6 +179,12 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu How long to keep checking the backend output for the frontend URL. Default: 10 seconds. session_creation_timeout : integer How long to keep checking the output for a default session ID. Default: 10 seconds. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -149,6 +201,10 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu If a valid URL cannot be obtained from the backend process output. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ backend = Backend(("--enable_scripting", *params), executable_path, remote_host, token, frontend_url_timeout, session_creation_timeout) if not backend.start(): @@ -162,10 +218,25 @@ def start_and_interact(cls, executable_path="carta", remote_host=None, params=tu backend.stop() raise CartaBadID("Could not parse default CARTA session ID from backend output.") - return cls.interact(backend.frontend_url, backend.last_session_id, token, backend.debug_no_auth, backend) + try: + return cls.interact( + backend.frontend_url, + backend.last_session_id, + token, + backend.debug_no_auth, + backend, + connection_check_timeout=connection_check_timeout, + version_mismatch_action=version_mismatch_action, + ) + except Exception: + try: + backend.stop() + except Exception: + logger.exception("Failed to stop CARTA backend after session creation failed.") + raise @classmethod - def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=False): + def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=False, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Connect to an existing CARTA backend or CARTA controller instance and create a new session. Parameters @@ -180,6 +251,12 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal The number of seconds to spend retrying parsing connection information from the frontend (default: 10). debug_no_auth : boolean, optional Disable authentication. Set this if the backend has been started with the ``--debug_no_auth`` option. This is provided for debugging purposes only and should not be used under normal circumstances. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -194,11 +271,23 @@ def create(cls, browser, frontend_url, token=None, timeout=10, debug_no_auth=Fal If an invalid URL was provided. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ - return browser.new_session_from_url(frontend_url, token, backend=None, timeout=timeout, debug_no_auth=debug_no_auth) + return browser.new_session_from_url( + frontend_url, + token, + backend=None, + timeout=timeout, + debug_no_auth=debug_no_auth, + connection_check_timeout=connection_check_timeout, + version_mismatch_action=version_mismatch_action, + ) @classmethod - def start_and_create(cls, browser, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10): + def start_and_create(cls, browser, executable_path="carta", remote_host=None, params=tuple(), timeout=10, token=None, frontend_url_timeout=10, connection_check_timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): """Start a new CARTA backend instance and create a new session. This method cannot be used with a CARTA controller instance (which already starts and stops backend instances for the user on demand). Parameters @@ -217,6 +306,12 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa The security token to use. Parsed from the backend output by default. frontend_url_timeout : integer How long to keep checking the backend output for the frontend URL. Default: 10 seconds. + connection_check_timeout : number, optional + Maximum time in seconds to wait for the startup validation action. + Default: 10 seconds. + version_mismatch_action : :obj:`carta.constants.VersionMismatchAction`, optional + Whether a version mismatch should produce a warning or raise an + exception. ``VersionMismatchAction.ERROR`` is the default. Returns ------- @@ -231,8 +326,21 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa If an invalid URL was provided. CartaBadSession If the session object could not be created. + CartaUnsupportedVersion + If the wrapper cannot verify the connected CARTA version or it + does not satisfy the minimum requirement, and + ``version_mismatch_action`` is ``VersionMismatchAction.ERROR``. """ - return browser.new_session_with_backend(executable_path, remote_host, params, timeout, token, frontend_url_timeout) + return browser.new_session_with_backend( + executable_path, + remote_host, + params, + timeout, + token, + frontend_url_timeout, + connection_check_timeout=connection_check_timeout, + version_mismatch_action=version_mismatch_action, + ) def __repr__(self): """A human-readable representation of this session object.""" @@ -258,6 +366,57 @@ def carta_version(self): """ return self.get_value("frontendVersion") + def _fetch_frontend_version(self, timeout=None): + kwargs = {"response_expected": True} + if timeout is not None: + kwargs["timeout"] = timeout + + return self.call_action("fetchParameter", Macro("", "frontendVersion"), **kwargs) + + def _cache_carta_version(self, version): + if not hasattr(self, "_cache"): + self._cache = {} + self._cache["carta_version"] = version + + @validate(Number(min=0), Constant(VersionMismatchAction)) + def _validate_session(self, timeout=10, version_mismatch_action=VersionMismatchAction.ERROR): + version_mismatch_action = VersionMismatchAction(version_mismatch_action) + + try: + version = self._fetch_frontend_version(timeout=timeout) + except (CartaActionFailed, CartaMissingResponse) as e: + current = latest_compatibility() + message = format_frontend_version_unavailable_error( + current, + include_warn_suggestion=version_mismatch_action is VersionMismatchAction.ERROR, + ) + if version_mismatch_action is VersionMismatchAction.ERROR: + raise CartaUnsupportedVersion(message) from e + logger.warning(message) + return None + except CartaScriptingException as e: + raise CartaBadSession( + format_session_validation_error( + session_id=self.session_id, + uri=self._protocol.frontend_url if self._protocol else None, + timeout=timeout, + error=e, + ) + ) from e + + self._cache_carta_version(version) + mismatches, suggestions = version_mismatch_details(version) + if mismatches: + message = format_version_mismatch_error( + mismatches, + suggestions, + include_warn_suggestion=version_mismatch_action is VersionMismatchAction.ERROR, + ) + if version_mismatch_action is VersionMismatchAction.ERROR: + raise CartaUnsupportedVersion(message) + logger.warning(message) + return version + def call_action(self, path, *args, **kwargs): """Call an action on the frontend through the backend's scripting interface. @@ -288,7 +447,17 @@ def call_action(self, path, *args, **kwargs): CartaBadResponse If a request which was expected to have a JSON response did not have one, or if a JSON response could not be decoded. """ - return self._protocol.request_scripting_action(self.session_id, path, *args, **kwargs) + try: + return self._protocol.request_scripting_action(self.session_id, path, *args, **kwargs) + except CartaActionFailed as error: + version = getattr(self, "_cache", {}).get("carta_version") + suggestions = action_failure_compatibility_suggestions(version) + if not suggestions: + raise + + message = f"{error}\n\nCompatibility suggestions:\n" + "\n".join(f"- {suggestion}" for suggestion in suggestions) + error.args = (message,) + raise def get_value(self, path, return_path=None): """Get the value of an attribute from a frontend store. diff --git a/carta/util.py b/carta/util.py index d623d5b..edfd231 100644 --- a/carta/util.py +++ b/carta/util.py @@ -40,7 +40,7 @@ class CartaBadUrl(CartaScriptingException): class CartaValidationFailed(CartaScriptingException): - """Invalid parameters were passed to a function with a :obj:`carta.validation.validate` decorator.""" + """A user-supplied parameter or requirement is invalid.""" pass @@ -59,11 +59,21 @@ class CartaActionFailed(CartaScriptingException): pass +class CartaUnsupportedVersion(CartaScriptingException): + """The connected CARTA version is not supported.""" + pass + + class CartaBadResponse(CartaScriptingException): """An action request received an unexpected response from the CARTA frontend.""" pass +class CartaMissingResponse(CartaBadResponse): + """An action request expected a response but received none.""" + pass + + class Macro: """A placeholder for a target and a variable which will be evaluated dynamically by the frontend. diff --git a/carta/version.py b/carta/version.py new file mode 100644 index 0000000..e8f1d79 --- /dev/null +++ b/carta/version.py @@ -0,0 +1,185 @@ +"""Helpers for validating CARTA version compatibility.""" + +import re +from dataclasses import dataclass, field + +from .compatibility import COMPATIBILITY_DATA + + +_VERSION_PATTERN = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-[A-Za-z0-9.]+)?") +_SERIES_PATTERN = re.compile(r"(\d+)\.(\d+)") + +Version = tuple[int, int, int] +VersionSeries = tuple[int, int] + + +def parse_carta_version(version: object) -> Version | None: + """Parse a CARTA version, ignoring any prerelease suffix.""" + match = _VERSION_PATTERN.fullmatch(str(version)) + if match is None: + return None + + major, minor, patch = match.groups() + return int(major), int(minor), int(patch) + + +def parse_version_series(series: object) -> VersionSeries | None: + """Parse a ``MAJOR.MINOR`` version series.""" + match = _SERIES_PATTERN.fullmatch(str(series)) + if match is None: + return None + + major, minor = match.groups() + return int(major), int(minor) + + +def _require_series(name: str, value: str) -> VersionSeries: + parsed = parse_version_series(value) + if parsed is None: + raise ValueError(f"{name} must be in MAJOR.MINOR format, got {value!r}") + return parsed + + +@dataclass(frozen=True) +class CompatibilityRange: + """CARTA version range and its recommended carta-python series. + + Bounds use ``MAJOR.MINOR`` granularity and are inclusive. An omitted upper + bound covers every later CARTA series until a known incompatibility closes + the range. + """ + + carta_min: str + carta_max: str | None + wrapper: str + _minimum: VersionSeries = field(init=False, repr=False, compare=False) + _maximum: VersionSeries | None = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + """Validate and cache the numeric version series.""" + minimum = _require_series("carta_min", self.carta_min) + maximum = ( + None + if self.carta_max is None + else _require_series("carta_max", self.carta_max) + ) + _require_series("wrapper", self.wrapper) + + if maximum is not None and maximum < minimum: + raise ValueError("carta_max must not be earlier than carta_min") + + object.__setattr__(self, "_minimum", minimum) + object.__setattr__(self, "_maximum", maximum) + + def covers_carta(self, version: object) -> bool: + """Return whether this range covers a CARTA version.""" + parsed_version = parse_carta_version(version) + if parsed_version is None: + return False + + series = parsed_version[:2] + if series < self._minimum: + return False + return self._maximum is None or series <= self._maximum + + @property + def carta_label(self) -> str: + """Return a human-readable CARTA version range.""" + if self.carta_max is None: + return f"{self.carta_min}+" + return f"{self.carta_min} - {self.carta_max}" + + @property + def wrapper_label(self) -> str: + """Return the recommended carta-python series label.""" + return f"{self.wrapper}.x" + + @property + def carta_minimum_version(self) -> str: + """Return the oldest full CARTA version covered by this range.""" + return f"{self.carta_min}.0" + + +COMPATIBILITY = tuple(CompatibilityRange(**entry) for entry in COMPATIBILITY_DATA) +"""Recommended CARTA and carta-python version combinations.""" + + +def latest_compatibility() -> CompatibilityRange: + """Return the newest range in the compatibility table.""" + return COMPATIBILITY[-1] + + +def compatibility_for_carta(version: object) -> CompatibilityRange | None: + """Return the compatibility range covering a CARTA version.""" + for compatibility in COMPATIBILITY: + if compatibility.covers_carta(version): + return compatibility + return None + + +def _older_carta_suggestions( + version: object, current: CompatibilityRange +) -> list[str]: + suggestions = [f"Upgrade CARTA to at least {current.carta_minimum_version!r}."] + + recommended = compatibility_for_carta(version) + if recommended is not None: + suggestions.append( + f"If CARTA cannot be upgraded, use carta-python {recommended.wrapper_label}, " + f"the recommended series for CARTA {recommended.carta_label}:\n" + f" python -m pip install --upgrade \"carta-python~={recommended.wrapper}.0\"\n" + " or, for a uv-managed script:\n" + f" uv add --script your_script.py \"carta-python~={recommended.wrapper}.0\" " + "--upgrade-package carta-python\n" + " uv run your_script.py." + ) + + return suggestions + + +def version_mismatch_details(version: object) -> tuple[list[str], list[str]]: + """Return connection problems and suggestions for a CARTA version.""" + parsed_version = parse_carta_version(version) + if parsed_version is None: + return ( + [f"frontend reported invalid CARTA version {version!r}."], + ["Verify that CARTA reports a valid MAJOR.MINOR.PATCH version."], + ) + + current = latest_compatibility() + if parsed_version[:2] >= current._minimum: + return [], [] + + mismatches = [ + f"CARTA version {version!r} is older than the minimum " + f"{current.carta_minimum_version!r} required for complete " + f"functionality with carta-python {current.wrapper_label}." + ] + return mismatches, _older_carta_suggestions(version, current) + + +def action_failure_compatibility_suggestions(version: object) -> list[str]: + """Return compatibility suggestions after a frontend action failure.""" + parsed_version = parse_carta_version(version) + if parsed_version is None: + return [] + + current = latest_compatibility() + if parsed_version[:2] < current._minimum: + return _older_carta_suggestions(version, current) + + return [ + "For direct scripting calls, verify that the frontend action, attribute, " + "or response path exists.", + "If this failure started after a CARTA upgrade, upgrade carta-python to " + "the latest available release and retry.\n" + "For a regular Python environment:\n" + " python -m pip install --upgrade carta-python\n" + "For a uv-managed script:\n" + " uv add --script your_script.py carta-python --upgrade-package carta-python\n" + " uv run your_script.py", + "If the failure persists after upgrading, check whether your script uses " + "a renamed carta-python function or argument, a direct `call_action()` " + "API path, or a changed response structure. Updating carta-python does " + "not rewrite hard-coded API paths in your script.", + ] diff --git a/docs/source/development.rst b/docs/source/development.rst new file mode 100644 index 0000000..f4ea4a8 --- /dev/null +++ b/docs/source/development.rst @@ -0,0 +1,23 @@ +Development +=========== + +Set up a development environment with ``uv``: + +.. code-block:: shell + + uv sync --all-extras + +Run the test suite and documentation build before submitting changes: + +.. code-block:: shell + + uv run pytest + uv run sphinx-build -W -b html docs/source docs/build/html + +Development topics +------------------ + +.. toctree:: + :maxdepth: 1 + + development/release-cycle diff --git a/docs/source/development/release-cycle.rst b/docs/source/development/release-cycle.rst new file mode 100644 index 0000000..482ea94 --- /dev/null +++ b/docs/source/development/release-cycle.rst @@ -0,0 +1,117 @@ +Version compatibility and release cycle +======================================= + +Version compatibility data +-------------------------- + +CARTA and ``carta-python`` compatibility is defined by +``COMPATIBILITY_DATA`` in ``carta/compatibility.py``. Each entry maps an +inclusive range of CARTA ``MAJOR.MINOR`` series to the latest recommended +``carta-python`` series. + +The final entry determines the requirements for the version under development: + +* ``carta_min`` defines the minimum CARTA series that provides complete + functionality. +* ``wrapper`` must match the major and minor components in ``VERSION.txt``. +* ``carta_max=None`` covers every later CARTA series until a known breaking + change closes the range. + +Keep all ranges ordered from oldest to newest and non-overlapping. Only the +final range may use ``carta_max=None``. Explicit ranges may span CARTA major +versions. + +Compatibility is forward-looking: a new CARTA major version does not by itself +require a table update or a new ``carta-python`` release. If the frontend APIs +used by the wrapper remain compatible, the existing open-ended range continues +to apply. Update the table only when a known frontend incompatibility or a new +wrapper feature changes the supported range. + +Starting a new development cycle +--------------------------------- + +After a release, prepare ``dev`` for the next release before merging feature +work: + +#. Choose the next version and add a ``-dev`` suffix in ``VERSION.txt``. For + example, start 2.1 development with ``2.1.0-dev``. +#. If the major/minor series changes, update ``wrapper`` in the final + compatibility entry to match. If the CARTA minimum is unchanged, update the + existing entry rather than adding an overlapping range. +#. For a patch cycle, such as ``2.0.0`` to ``2.0.1-dev``, leave ``wrapper`` as + ``"2.0"`` because the compatibility table tracks major/minor series. +#. Run the focused version tests. + +For example, starting 2.1 development while retaining CARTA 6.1 as the minimum +uses: + +.. code-block:: text + + 2.1.0-dev + +in ``VERSION.txt`` and: + +.. code-block:: python + + COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": None, "wrapper": "2.1"}, + ) + +If the next release is already known to require a newer CARTA series, apply the +range update described below in the same cycle-preparation pull request. + +Pull requests during a development cycle +----------------------------------------- + +Ordinary feature and fix pull requests target ``dev``. They should not change +``VERSION.txt`` or the compatibility table unless they alter CARTA version +requirements. + +When a pull request introduces a dependency on a newer CARTA series, update the +compatibility table, its tests, and its documentation in that same pull +request. Do not defer the compatibility change to the release pull request. +Increasing the minimum requires a new ``carta-python`` major/minor series; if +the current cycle is a patch cycle, start an appropriate major/minor cycle +first. + +Close the previous range and append a range for the new minimum. For example, +if the version under development is 2.1 and a pull request raises the minimum +CARTA series from 6.1 to 6.2, change the table from: + +.. code-block:: python + + COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": None, "wrapper": "2.1"}, + ) + +to: + +.. code-block:: python + + COMPATIBILITY_DATA = ( + {"carta_min": "6.1", "carta_max": "6.1", "wrapper": "2.0"}, + {"carta_min": "6.2", "carta_max": None, "wrapper": "2.1"}, + ) + +This preserves the recommendation for older CARTA releases while making 6.2 +the minimum for the version under development. The same procedure applies +across CARTA major versions: close the previous range at the last compatible +``MAJOR.MINOR`` series, then append the new open-ended range. Do not split a +range merely because CARTA increments its major version. + +Finishing a development cycle +----------------------------- + +The release pull request from ``dev`` to ``main`` finalizes ``VERSION.txt`` by +removing the ``-dev`` suffix, for example from ``2.1.0-dev`` to ``2.1.0``. It +must verify that the final compatibility entry already matches the release +major/minor version and minimum CARTA series. It should not be the first place +where a compatibility change is recorded. + +Run the full test suite and documentation build before merging the release. If +the compatibility table changed during the cycle, also run its focused tests: + +.. code-block:: shell + + uv run pytest + uv run sphinx-build -W -b html docs/source docs/build/html diff --git a/docs/source/index.rst b/docs/source/index.rst index 6d4c9fa..8183f17 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,4 +7,5 @@ carta-python: a scripting wrapper for CARTA introduction quickstart + development carta diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 253e242..2588a0a 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -4,29 +4,135 @@ CARTA scripting quick start Installation ------------ -This package is not yet published on PyPi, but can be installed from a local checkout of the repository. +The initial PyPI release, ``carta-python`` 2.0.0, supports CARTA 6.1.0 or +later. Session creation inspects the CARTA version reported by the frontend +and reports a version mismatch when this requirement is not met. As new +``carta-python`` series are published, the compatibility table retains the +recommended series for older CARTA releases. + +The recommended installation workflow is: + +#. Install the latest available ``carta-python`` release. +#. Connect to the desired CARTA session as described in + `Connecting to an existing interactive session`_. +#. If session creation reports a version mismatch, follow its suggestion to + upgrade CARTA or install the recommended older ``carta-python`` series. +#. Pin the selected release in the script or project so future installations + use the same compatible version. + +For the initial 2.0.0 release, a version mismatch only recommends upgrading +CARTA because no older ``carta-python`` releases exist on PyPI. Once multiple +series have been published, a mismatch may also recommend an older series. +For example, the following hypothetical future compatibility table response +shows ``carta-python`` 3.0.x recommending 2.0.x for CARTA 6.1: + +.. code-block:: text + + CartaUnsupportedVersion: CARTA version validation failed: + - CARTA version '6.1.0' is older than the minimum '7.1.0' required for complete functionality with carta-python 3.0.x. + + Suggested actions: + - Upgrade CARTA to at least '7.1.0'. + - If CARTA cannot be upgraded, use carta-python 2.0.x, the recommended series for CARTA 6.1 - 7.0. + - If this combination is known to work, set `version_mismatch_action=VersionMismatchAction.WARN`. Requires **Python 3.10** or later. Install with ``pip``: .. code-block:: shell - git clone https://github.com/CARTAvis/carta-python.git - cd carta-python - pip install . + pip install --upgrade carta-python + +If the compatibility error recommends an older published series, install the +latest release in that series. For example, for a recommendation of +``2.0.x``: + +.. code-block:: shell + + pip install --upgrade "carta-python~=2.0.0" + +If your script is managed with ``uv``, add the pinned dependency to the +script's inline metadata and run the script with ``uv run``. The following +example selects the latest release in the ``2.0.x`` series: + +.. code-block:: shell + + uv add --script your_script.py "carta-python~=2.0.0" --upgrade-package carta-python + uv run your_script.py + +This updates the PEP 723 dependency metadata at the top of +``your_script.py``. It will look similar to: + +.. code-block:: python + + # /// script + # dependencies = [ + # "carta-python~=2.0.0", + # ] + # /// + +To add the latest ``carta-python`` release without selecting a specific +series, use: + +.. code-block:: shell + + uv add --script your_script.py carta-python --upgrade-package carta-python + uv run your_script.py + +If the script already contains an exact pin such as +``carta-python==1.0.0``, specify the new requirement explicitly with +``uv add --script``. Updating a package in the system Python environment with +``pip`` does not change the isolated environment used by ``uv run``. The required Python library dependencies should be installed automatically. To create new frontend sessions which are controlled by the wrapper instead of connecting to existing frontend sessions, you also need to install the optional browser dependencies: .. code-block:: shell - pip install ".[browser]" + pip install --upgrade "carta-python[browser]" -You need access either to a CARTA backend executable, on the local host or on a remote host which you can access through SSH, or to a CARTA controller instance (a multi-user system with web-based authentication). You must be able to access the frontend served by this CARTA instance. If you are using your own backend executable, you must start it with the ``--enable_scripting`` commandline parameter to enable the scripting interface. +For a script managed with ``uv``, add the optional extra to the script's inline +metadata: -.. note:: - This version of the wrapper requires at least the 4.0 release versions of the CARTA backend, frontend and (optionally) controller. Older versions of these components may not work correctly and are not supported. +.. code-block:: shell + + uv add --script your_script.py "carta-python[browser]" + uv run your_script.py + +To pin a browser-enabled release series, combine the extra with a version +constraint, for example: + +.. code-block:: shell + + uv add --script your_script.py "carta-python[browser]~=2.0.0" --upgrade-package carta-python + uv run your_script.py + +If ``your_script.py`` already pins ``carta-python``, include the existing +version constraint when adding the ``browser`` extra. For example, replace +``carta-python~=2.0.0`` with ``carta-python[browser]~=2.0.0`` in one command. +Adding ``carta-python[browser]`` without a version constraint can replace the +existing requirement and remove its version restriction. + +You need access either to a CARTA backend executable, on the local host or on a remote host which you can access through SSH, or to a CARTA controller instance (a multi-user system with web-based authentication). You must be able to access the frontend served by this CARTA instance. If you are using your own backend executable, you must start it with the ``--enable_scripting`` commandline parameter to enable the scripting interface. If you want to create browser sessions from the wrapper, you also need to make sure that your desired browser is installed, together with a corresponding web driver. At present only Chrome (or Chromium) can be used for headless sessions. +CARTA version compatibility +---------------------------- + +Every session creation method checks that the CARTA version is compatible +with the minimum version supported by the installed ``carta-python`` wrapper. +The wrapper's minimum is maintained by the package and is not set by the +script. Version mismatches raise an exception by default. Use +``version_mismatch_action=VersionMismatchAction.WARN`` to continue with a +warning and suggested actions instead. + +When a script needs a specific ``carta-python`` version, pin that dependency +in the script's ``uv`` metadata rather than passing a CARTA version to the +session API. + +If session creation fails during startup validation, check that the frontend +URL is reachable, the session ID is correct, the token is valid, and the +backend was started with ``--enable_scripting``. + Connecting to an existing interactive session --------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 6828222..31d768a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61"] build-backend = "setuptools.build_meta" [project] -name = "carta" +name = "carta-python" dynamic = ["version"] description = "CARTA scripting wrapper written in Python" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/tests/test_browser.py b/tests/test_browser.py new file mode 100644 index 0000000..5c82ec1 --- /dev/null +++ b/tests/test_browser.py @@ -0,0 +1,236 @@ +import pytest + +from carta.constants import VersionMismatchAction + + +class FakeElement: + def __init__(self, text): + self.text = text + + def get_attribute(self, name): + assert name == "textContent" + return self.text + + +class FakeDriver: + def __init__(self, session_id="123", close_error=None): + self.session_id = session_id + self.close_error = close_error + self.urls = [] + self.closed = False + self.cookies = [] + + def get(self, url): + self.urls.append(url) + + def add_cookie(self, cookie): + self.cookies.append(cookie) + + def find_element(self, by, value): + return FakeElement(self.session_id) + + def quit(self): + if self.close_error: + raise self.close_error + self.closed = True + + +@pytest.fixture +def browser_module(): + pytest.importorskip("selenium") + import carta.browser + + return carta.browser + + +@pytest.fixture +def util_module(): + pytest.importorskip("selenium") + import carta.util + + return carta.util + + +def make_browser(browser_class, session_id="123", close_error=None): + browser = browser_class.__new__(browser_class) + browser.driver = FakeDriver(session_id=session_id, close_error=close_error) + return browser + + +def test_new_session_from_url_runs_connection_check_after_parsing_session_id( + mocker, browser_module +): + browser = make_browser(browser_module.Browser) + protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") + mocker.patch("carta.browser.Protocol", return_value=protocol) + session = mocker.Mock() + session_class = mocker.patch("carta.browser.Session", return_value=session) + + result = browser.new_session_from_url( + "http://localhost:3000?token=x", + connection_check_timeout=5, + ) + + assert result is session + session_class.assert_called_once_with(123, protocol, browser=browser, backend=None) + session._validate_session.assert_called_once_with( + timeout=5, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + assert browser.driver.closed is False + + +def test_new_session_from_url_passes_mismatch_action( + mocker, browser_module +): + browser = make_browser(browser_module.Browser) + protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") + mocker.patch("carta.browser.Protocol", return_value=protocol) + session = mocker.Mock() + mocker.patch("carta.browser.Session", return_value=session) + + browser.new_session_from_url( + "http://localhost:3000?token=x", + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + session._validate_session.assert_called_once_with( + timeout=10, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + +def test_new_session_from_url_closes_browser_and_preserves_validation_error( + mocker, browser_module, util_module +): + browser = make_browser(browser_module.Browser) + protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") + mocker.patch("carta.browser.Protocol", return_value=protocol) + session = mocker.Mock() + session._validate_session.side_effect = util_module.CartaUnsupportedVersion("bad version") + mocker.patch("carta.browser.Session", return_value=session) + + with pytest.raises(util_module.CartaUnsupportedVersion): + browser.new_session_from_url( + "http://localhost:3000?token=x", + ) + + assert browser.driver.closed is True + + +def test_new_session_from_url_preserves_validation_error_when_close_fails( + mocker, browser_module, util_module +): + close_error = RuntimeError("close failed") + browser = make_browser(browser_module.Browser, close_error=close_error) + protocol = mocker.Mock(controller_auth=False, frontend_url="http://localhost:3000") + mocker.patch("carta.browser.Protocol", return_value=protocol) + session = mocker.Mock() + session._validate_session.side_effect = util_module.CartaUnsupportedVersion("bad version") + mocker.patch("carta.browser.Session", return_value=session) + + with pytest.raises(util_module.CartaUnsupportedVersion): + browser.new_session_from_url( + "http://localhost:3000?token=x", + ) + + +def test_new_session_with_backend_stops_backend_when_session_creation_fails( + mocker, browser_module, util_module +): + browser = make_browser(browser_module.Browser) + backend = mocker.Mock( + frontend_url="http://localhost:3000", + token="token", + debug_no_auth=False, + errors=[], + ) + backend.start.return_value = True + mocker.patch("carta.browser.Backend", return_value=backend) + mocker.patch.object( + browser, + "new_session_from_url", + side_effect=util_module.CartaBadSession("startup failed"), + ) + + with pytest.raises(util_module.CartaBadSession): + browser.new_session_with_backend() + + backend.stop.assert_called_once_with() + browser.new_session_from_url.assert_called_once_with( + "http://localhost:3000", + "token", + backend=backend, + timeout=10, + debug_no_auth=False, + connection_check_timeout=10, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + +def test_new_session_with_backend_preserves_error_when_backend_stop_fails( + mocker, browser_module, util_module +): + browser = make_browser(browser_module.Browser) + backend = mocker.Mock( + frontend_url="http://localhost:3000", + token="token", + debug_no_auth=False, + errors=[], + ) + backend.start.return_value = True + backend.stop.side_effect = RuntimeError("stop failed") + mocker.patch("carta.browser.Backend", return_value=backend) + startup_error = util_module.CartaBadSession("startup failed") + mocker.patch.object(browser, "new_session_from_url", side_effect=startup_error) + + with pytest.raises(util_module.CartaBadSession, match="startup failed"): + browser.new_session_with_backend() + + backend.stop.assert_called_once_with() + + +def test_new_session_with_backend_passes_version_options( + mocker, browser_module +): + browser = make_browser(browser_module.Browser) + backend = mocker.Mock( + frontend_url="http://localhost:3000", + token="token", + debug_no_auth=False, + errors=[], + ) + backend.start.return_value = True + mocker.patch("carta.browser.Backend", return_value=backend) + session = mocker.Mock() + new_session = mocker.patch.object(browser, "new_session_from_url", return_value=session) + + result = browser.new_session_with_backend( + connection_check_timeout=4, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + assert result is session + new_session.assert_called_once_with( + "http://localhost:3000", + "token", + backend=backend, + timeout=10, + debug_no_auth=False, + connection_check_timeout=4, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + +def test_new_session_with_backend_stops_backend_when_frontend_url_missing( + mocker, browser_module, util_module +): + browser = make_browser(browser_module.Browser) + backend = mocker.Mock(frontend_url=None, errors=[]) + backend.start.return_value = True + mocker.patch("carta.browser.Backend", return_value=backend) + + with pytest.raises(util_module.CartaBadSession): + browser.new_session_with_backend() + + backend.stop.assert_called_once_with() diff --git a/tests/test_session.py b/tests/test_session.py index e963cad..ae09e57 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -6,8 +6,24 @@ from carta.image import Image from carta.color_blending import ColorBlending from carta.session import Session -from carta.util import CartaActionFailed, CartaBadResponse, CartaValidationFailed, Macro, Point as Pt -from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol +from carta.util import ( + CartaActionFailed, + CartaBadResponse, + CartaBadSession, + CartaMissingResponse, + CartaRequestFailed, + CartaUnsupportedVersion, + CartaValidationFailed, + Macro, + Point as Pt, +) +from carta.constants import ( + ColormapSet, + ComplexComponent as CC, + ImageType, + Polarization as Pol, + VersionMismatchAction, +) # FIXTURES @@ -35,6 +51,7 @@ def method(session, mock_method): ("wcs", "SessionWCSOverlay"), ]) def test_subobjects(session, name, classname): + assert isinstance(session, Session) assert getattr(session, name).__class__.__name__ == classname @@ -81,6 +98,360 @@ def test_session_repr_omits_carta_version_when_lookup_fails(session, mocker): assert repr(session) == "Session(session_id=0, uri='http://localhost:3000')" +def test_direct_session_construction_does_not_validate_session(mocker): + validate_session = mocker.patch.object(Session, "_validate_session") + + session = Session(0, None) + + assert session.session_id == 0 + validate_session.assert_not_called() + + +def test_validate_session_fetches_frontend_version_with_timeout(session, call_action): + call_action.return_value = "6.1.0-dev" + + assert session._validate_session(timeout=3) == "6.1.0-dev" + + call_action.assert_called_once_with( + "fetchParameter", + Macro("", "frontendVersion"), + response_expected=True, + timeout=3, + ) + assert session.carta_version == "6.1.0-dev" + + +def test_validate_session_warns_for_unsupported_version_when_requested( + session, call_action, caplog +): + call_action.return_value = "6.0.0" + + session._validate_session( + timeout=3, version_mismatch_action=VersionMismatchAction.WARN + ) + + assert "older than the minimum" in caplog.text + assert "complete functionality with carta-python 2.0.x" in caplog.text + assert "\n\nSuggested actions:\n" in caplog.text + assert "Upgrade CARTA to at least '6.1.0'." in caplog.text + assert "version_mismatch_action=VersionMismatchAction.WARN" not in caplog.text + + +def test_validate_session_raises_for_unsatisfied_minimum_in_error_mode( + session, call_action +): + call_action.return_value = "5.9.0" + + with pytest.raises(CartaUnsupportedVersion, match="older than the minimum"): + session._validate_session( + timeout=3, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + +@pytest.mark.parametrize( + "version_mismatch_action", + [VersionMismatchAction.WARN, VersionMismatchAction.ERROR], +) +def test_validate_session_accepts_newer_frontend_major( + session, call_action, caplog, version_mismatch_action +): + call_action.return_value = "7.0.0" + + assert session._validate_session( + timeout=3, + version_mismatch_action=version_mismatch_action, + ) == "7.0.0" + + assert not caplog.text + + +def test_validate_session_validates_action_before_fetching_version(session, call_action): + with pytest.raises(CartaValidationFailed): + session._validate_session(timeout=3, version_mismatch_action="invalid") + + call_action.assert_not_called() + + +def test_validate_session_reports_invalid_frontend_version_in_error_mode( + session, call_action +): + call_action.return_value = "bad.version" + + with pytest.raises(CartaUnsupportedVersion, match="invalid CARTA version"): + session._validate_session( + timeout=2, version_mismatch_action=VersionMismatchAction.ERROR + ) + + +@pytest.mark.parametrize( + "error", + [ + CartaActionFailed("frontendVersion unavailable"), + CartaMissingResponse("frontendVersion returned no response"), + ], +) +def test_validate_session_reports_unavailable_frontend_version_in_error_mode( + session, call_action, error +): + call_action.side_effect = error + + with pytest.raises(CartaUnsupportedVersion) as e: + session._validate_session(timeout=2) + + message = str(e.value) + assert "Could not retrieve `frontendVersion`" in message + assert "earlier than '6.0.0'" in message + assert "carta-python 2.0.x requires CARTA '6.1.0' or later" in message + assert "\n\nSuggested actions:\n" in message + assert "Upgrade CARTA to at least '6.1.0'." in message + assert "CARTA is already '6.0.0' or newer" in message + assert "version_mismatch_action=VersionMismatchAction.WARN" in message + assert "Original error" not in message + assert e.value.__cause__ is error + + +def test_validate_session_warns_when_frontend_version_is_unavailable( + session, call_action, caplog +): + call_action.side_effect = CartaMissingResponse( + "frontendVersion returned no response" + ) + + assert session._validate_session( + timeout=2, + version_mismatch_action=VersionMismatchAction.WARN, + ) is None + + assert "Could not retrieve `frontendVersion`" in caplog.text + assert "\n\nSuggested actions:\n" in caplog.text + assert "Original error" not in caplog.text + assert "version_mismatch_action=VersionMismatchAction.WARN" not in caplog.text + assert not hasattr(session, "_cache") + + +def test_validate_session_wraps_connection_failure(session, call_action, mocker): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + call_action.side_effect = CartaRequestFailed("session unavailable") + + with pytest.raises(CartaBadSession) as e: + session._validate_session(timeout=2) + + message = str(e.value) + assert "Could not validate CARTA session 0" in message + assert "http://localhost:3000" in message + assert "2" in message + assert "CartaRequestFailed" in message + assert "session unavailable" in message + assert "Possible causes:" not in message + + +def test_call_action_adds_compatibility_suggestion_to_frontend_failure( + session, mocker +): + original_error = CartaActionFailed("newAction is unavailable") + session._cache = {"carta_version": "7.0.0"} + session._protocol = mocker.Mock() + session._protocol.request_scripting_action.side_effect = original_error + + with pytest.raises(CartaActionFailed) as error: + session.call_action("newAction") + + message = str(error.value) + assert error.value is original_error + assert "newAction is unavailable" in message + assert "\n\nCompatibility suggestions:\n" in message + assert "verify that the frontend action, attribute, or response path exists" in message + assert "upgrade carta-python to the latest available release" in message + + +def test_call_action_preserves_frontend_failure_without_cached_version( + session, mocker +): + original_error = CartaActionFailed("newAction is unavailable") + session._protocol = mocker.Mock() + session._protocol.request_scripting_action.side_effect = original_error + + with pytest.raises(CartaActionFailed) as error: + session.call_action("newAction") + + assert error.value is original_error + + +def test_call_action_does_not_add_compatibility_suggestion_to_request_failure( + session, mocker +): + original_error = CartaRequestFailed("session is unavailable") + session._cache = {"carta_version": "7.0.0"} + session._protocol = mocker.Mock() + session._protocol.request_scripting_action.side_effect = original_error + + with pytest.raises(CartaRequestFailed) as error: + session.call_action("newAction") + + assert error.value is original_error + + +def test_interact_checks_connection_by_default(mocker): + protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch("carta.session.Protocol", return_value=protocol) + validate_session = mocker.patch.object(Session, "_validate_session") + + session = Session.interact("http://localhost:3000?token=x", "123") + + assert session.session_id == 123 + assert session._protocol is protocol + validate_session.assert_called_once_with( + timeout=10, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + +def test_interact_passes_connection_check_timeout(mocker): + protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch("carta.session.Protocol", return_value=protocol) + validate_session = mocker.patch.object(Session, "_validate_session") + + Session.interact( + "http://localhost:3000?token=x", + 123, + connection_check_timeout=4, + ) + + validate_session.assert_called_once_with( + timeout=4, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + +def test_interact_always_validates(mocker): + protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch("carta.session.Protocol", return_value=protocol) + validate_session = mocker.patch.object(Session, "_validate_session") + + Session.interact( + "http://localhost:3000?token=x", + 123, + ) + + validate_session.assert_called_once_with( + timeout=10, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + +def test_interact_passes_mismatch_action(mocker): + protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch("carta.session.Protocol", return_value=protocol) + validate_session = mocker.patch.object(Session, "_validate_session") + + Session.interact( + "http://localhost:3000?token=x", + 123, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + validate_session.assert_called_once_with( + timeout=10, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + +def test_start_and_interact_stops_backend_when_connection_check_fails(mocker): + backend = mocker.Mock( + frontend_url="http://localhost:3000", + last_session_id=123, + debug_no_auth=False, + errors=[], + ) + backend.start.return_value = True + mocker.patch("carta.session.Backend", return_value=backend) + mocker.patch.object( + Session, + "interact", + side_effect=CartaBadSession("startup check failed"), + ) + + with pytest.raises(CartaBadSession): + Session.start_and_interact() + + backend.stop.assert_called_once_with() + + +def test_start_and_interact_preserves_error_when_backend_stop_fails(mocker): + backend = mocker.Mock( + frontend_url="http://localhost:3000", + last_session_id=123, + debug_no_auth=False, + errors=[], + ) + backend.start.return_value = True + backend.stop.side_effect = RuntimeError("stop failed") + mocker.patch("carta.session.Backend", return_value=backend) + startup_error = CartaBadSession("startup check failed") + mocker.patch.object(Session, "interact", side_effect=startup_error) + + with pytest.raises(CartaBadSession, match="startup check failed"): + Session.start_and_interact() + + backend.stop.assert_called_once_with() + + +def test_create_passes_connection_check_options_to_browser(mocker): + browser = mocker.Mock() + expected_session = mocker.Mock() + browser.new_session_from_url.return_value = expected_session + + result = Session.create( + browser, + "http://localhost:3000?token=x", + token="token", + timeout=7, + debug_no_auth=True, + connection_check_timeout=4, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + assert result is expected_session + browser.new_session_from_url.assert_called_once_with( + "http://localhost:3000?token=x", + "token", + backend=None, + timeout=7, + debug_no_auth=True, + connection_check_timeout=4, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + +def test_start_and_create_passes_connection_check_options_to_browser(mocker): + browser = mocker.Mock() + expected_session = mocker.Mock() + browser.new_session_with_backend.return_value = expected_session + + result = Session.start_and_create( + browser, + executable_path="carta-custom", + remote_host="remote", + params=("--verbosity", "5"), + timeout=7, + token="token", + frontend_url_timeout=8, + connection_check_timeout=4, + version_mismatch_action=VersionMismatchAction.ERROR, + ) + + assert result is expected_session + browser.new_session_with_backend.assert_called_once_with( + "carta-custom", + "remote", + ("--verbosity", "5"), + 7, + "token", + 8, + connection_check_timeout=4, + version_mismatch_action=VersionMismatchAction.ERROR, + ) # PATHS diff --git a/tests/test_util.py b/tests/test_util.py index 7b7e8a0..1bf916c 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -2,7 +2,10 @@ import pytest -from carta.util import Point as Pt, deprecated +from carta.util import ( + Point as Pt, + deprecated, +) def test_deprecated_warns_and_preserves_function_metadata(): diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..fcb9122 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,236 @@ +import pathlib + +import pytest + +from carta.version import ( + COMPATIBILITY, + CompatibilityRange, + action_failure_compatibility_suggestions, + compatibility_for_carta, + latest_compatibility, + parse_carta_version, + parse_version_series, + version_mismatch_details, +) + +VERSION_FILE = pathlib.Path(__file__).parent.parent / "VERSION.txt" + + +@pytest.mark.parametrize( + "version", + [ + "6.1.0", + "6.1.0-dev", + "6.1.0-beta.1", + "6.1.0-rc.1", + ], +) +def test_parse_carta_version_accepts_single_versions(version): + assert parse_carta_version(version) == (6, 1, 0) + + +@pytest.mark.parametrize( + "version", + [ + "", + "6.1", + ">=6.1.0", + "6.1.0,<7.0.0", + "bad.version", + ], +) +def test_parse_carta_version_rejects_invalid_versions(version): + assert parse_carta_version(version) is None + + +def test_version_mismatch_details_reports_old_frontend_version(): + assert version_mismatch_details("5.9.0") == ( + [ + "CARTA version '5.9.0' is older than the minimum '6.1.0' required " + "for complete functionality with carta-python 2.0.x." + ], + ["Upgrade CARTA to at least '6.1.0'."], + ) + + +def test_version_mismatch_details_uses_table_for_downgrade_suggestion(mocker): + table = ( + CompatibilityRange(carta_min="5.0", carta_max="5.9", wrapper="1.0"), + CompatibilityRange(carta_min="6.1", carta_max=None, wrapper="2.0"), + ) + mocker.patch("carta.version.COMPATIBILITY", table) + + assert version_mismatch_details("5.9.0") == ( + [ + "CARTA version '5.9.0' is older than the minimum '6.1.0' required " + "for complete functionality with carta-python 2.0.x." + ], + [ + "Upgrade CARTA to at least '6.1.0'.", + "If CARTA cannot be upgraded, use carta-python 1.0.x, the recommended series " + "for CARTA 5.0 - 5.9:\n" + " python -m pip install --upgrade \"carta-python~=1.0.0\"\n" + " or, for a uv-managed script:\n" + " uv add --script your_script.py \"carta-python~=1.0.0\" " + "--upgrade-package carta-python\n" + " uv run your_script.py.", + ], + ) + + +@pytest.mark.parametrize("version", ["6.1.0", "6.9.9", "7.0.0", "8.1.0"]) +def test_version_mismatch_details_accepts_current_and_newer_carta(version): + assert version_mismatch_details(version) == ([], []) + + +def test_version_mismatch_details_reports_invalid_frontend_version(): + assert version_mismatch_details("bad.version") == ( + ["frontend reported invalid CARTA version 'bad.version'."], + ["Verify that CARTA reports a valid MAJOR.MINOR.PATCH version."], + ) + + +def test_action_failure_suggests_latest_carta_python_for_supported_carta(): + assert action_failure_compatibility_suggestions("6.1.0") == [ + "For direct scripting calls, verify that the frontend action, attribute, " + "or response path exists.", + "If this failure started after a CARTA upgrade, upgrade carta-python to " + "the latest available release and retry.\n" + "For a regular Python environment:\n" + " python -m pip install --upgrade carta-python\n" + "For a uv-managed script:\n" + " uv add --script your_script.py carta-python --upgrade-package carta-python\n" + " uv run your_script.py", + "If the failure persists after upgrading, check whether your script uses " + "a renamed carta-python function or argument, a direct `call_action()` " + "API path, or a changed response structure. Updating carta-python does " + "not rewrite hard-coded API paths in your script.", + ] + + +def test_action_failure_suggests_latest_carta_python_for_newer_carta(): + assert action_failure_compatibility_suggestions("7.0.0") == [ + "For direct scripting calls, verify that the frontend action, attribute, " + "or response path exists.", + "If this failure started after a CARTA upgrade, upgrade carta-python to " + "the latest available release and retry.\n" + "For a regular Python environment:\n" + " python -m pip install --upgrade carta-python\n" + "For a uv-managed script:\n" + " uv add --script your_script.py carta-python --upgrade-package carta-python\n" + " uv run your_script.py", + "If the failure persists after upgrading, check whether your script uses " + "a renamed carta-python function or argument, a direct `call_action()` " + "API path, or a changed response structure. Updating carta-python does " + "not rewrite hard-coded API paths in your script.", + ] + + +def test_action_failure_uses_table_for_older_carta(mocker): + table = ( + CompatibilityRange(carta_min="5.0", carta_max="5.9", wrapper="1.0"), + CompatibilityRange(carta_min="6.1", carta_max=None, wrapper="2.0"), + ) + mocker.patch("carta.version.COMPATIBILITY", table) + + assert action_failure_compatibility_suggestions("5.9.0") == [ + "Upgrade CARTA to at least '6.1.0'.", + "If CARTA cannot be upgraded, use carta-python 1.0.x, the recommended series for " + "CARTA 5.0 - 5.9:\n" + " python -m pip install --upgrade \"carta-python~=1.0.0\"\n" + " or, for a uv-managed script:\n" + " uv add --script your_script.py \"carta-python~=1.0.0\" " + "--upgrade-package carta-python\n" + " uv run your_script.py.", + ] + + +def test_action_failure_ignores_unknown_carta_version(): + assert action_failure_compatibility_suggestions("bad.version") == [] + + +def assert_table_is_ordered(table): + for previous, current in zip(table, table[1:]): + current_min = parse_version_series(current.carta_min) + assert previous.carta_max is not None + assert parse_version_series(previous.carta_max) < current_min + assert parse_version_series(previous.wrapper) < parse_version_series(current.wrapper) + + +def test_compatibility_table_is_ordered_and_does_not_overlap(): + assert_table_is_ordered(COMPATIBILITY) + + +def test_open_ended_range_must_be_the_final_entry(): + table = ( + CompatibilityRange(carta_min="6.1", carta_max=None, wrapper="2.0"), + CompatibilityRange(carta_min="7.0", carta_max=None, wrapper="3.0"), + ) + + with pytest.raises(AssertionError): + assert_table_is_ordered(table) + + +def test_open_ended_range_covers_later_carta_major_versions(): + entry = CompatibilityRange(carta_min="6.1", carta_max=None, wrapper="2.0") + + assert entry.covers_carta("6.9.0") + assert entry.covers_carta("7.0.0") + assert entry.covers_carta("8.1.0") + + +def test_compatibility_table_bounds_are_valid_series(): + for entry in COMPATIBILITY: + for bound in (entry.carta_min, entry.wrapper): + assert parse_version_series(bound) is not None + assert entry.carta_max is None or parse_version_series(entry.carta_max) is not None + + +def test_explicit_compatibility_range_may_span_carta_major_versions(): + entry = CompatibilityRange(carta_min="6.1", carta_max="7.2", wrapper="2.0") + + assert entry.covers_carta("7.2.9") + assert not entry.covers_carta("7.3.0") + + +def test_package_version_matches_latest_compatibility(): + package_version = parse_carta_version(VERSION_FILE.read_text().strip()) + recommended_series = parse_version_series(latest_compatibility().wrapper) + + assert package_version[:2] == recommended_series + + +@pytest.mark.parametrize( + "version", ["6.1.0", "6.1.0-dev", "6.9.9", "7.0.0", "8.1.0"] +) +def test_compatibility_for_carta_finds_supported_versions(version): + assert compatibility_for_carta(version) is latest_compatibility() + + +@pytest.mark.parametrize("version", ["6.0.0", "5.9.0", "bad.version"]) +def test_compatibility_for_carta_rejects_unsupported_versions(version): + assert compatibility_for_carta(version) is None + + +def test_compatibility_labels(): + entry = latest_compatibility() + + assert entry.carta_label == "6.1+" + assert entry.wrapper_label == "2.0.x" + + +def test_compatibility_label_with_explicit_cross_major_maximum(): + entry = CompatibilityRange(carta_min="6.0", carta_max="7.2", wrapper="1.2") + + assert entry.carta_label == "6.0 - 7.2" + assert entry.wrapper_label == "1.2.x" + assert entry.covers_carta("7.2.5") + assert not entry.covers_carta("7.3.0") + + +@pytest.mark.parametrize( + "series", + ["", "6", "6.1.0", "6.x", "bad.series"], +) +def test_parse_version_series_rejects_invalid_series(series): + assert parse_version_series(series) is None diff --git a/uv.lock b/uv.lock index 83b08f9..d4c5727 100644 --- a/uv.lock +++ b/uv.lock @@ -30,7 +30,7 @@ wheels = [ ] [[package]] -name = "carta" +name = "carta-python" source = { editable = "." } dependencies = [ { name = "requests" }, @@ -383,7 +383,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [