diff --git a/docs/development/guide.rst b/docs/development/guide.rst index adbca7c..a48bb8f 100644 --- a/docs/development/guide.rst +++ b/docs/development/guide.rst @@ -20,7 +20,7 @@ The folllowing commands demonstrate how to do this using ``conda`` (assuming you conda create -n ffpy -c conda-forge python jupyter astropy # jupyter and astropy are needed for running examples conda activate ffpy - pip install -e ".[docs]" # editable installation with docs dependencies + pip install -e ".[tests,docs]" # editable installation with tests and docs dependencies Now you can run the examples notebooks/scripts in the ``examples/`` directory. @@ -53,7 +53,18 @@ above command and reload the above html file in browser. The Sphinx docs include rendered Jupyter notebooks (via ``nbsphinx``), which can require **pandoc**. If you see an error like ``PandocMissing``, install pandoc first (e.g., ``brew install pandoc`` on macOS). +Unit Tests +---------- + +Unit tests live in the ``tests/`` directory and use ``pytest``. +Make sure you have the virtual environment activated with test dependencies installed (``[tests]``), then run: + +.. code-block:: shell + + pytest tests/ + Development Tests/Examples -------------------------- -Refer to the `examples/development_tests directory `_ of firefly-client GitHub repository. +The ``examples/development_tests/`` directory contains notebooks for manually testing behaviour that requires a live Firefly server. +Refer to the `examples/development_tests directory `_ of the firefly_client GitHub repository. diff --git a/docs/development/new-release-procedure.md b/docs/development/new-release-procedure.md index acc741b..b5e0c25 100644 --- a/docs/development/new-release-procedure.md +++ b/docs/development/new-release-procedure.md @@ -2,7 +2,9 @@ ## Procedure 1. To push a new release you must be a maintainer in pypi ([see pypi below](#pypi)) -2. Bump version in pyproject.toml (this step might be done in the PR) +2. Bump versions (this step might be done in the PR): + - Upgrade project.version in pyproject.toml + - If this release depends on the updates made in the Firefly server, **wait** until a firefly release is made. Then update the minimum compatible server version in `firefly_client/_server_compat.py` (the `MIN_SERVER_VERSION` variable) to the version of the new firefly release. 3. Clean out old distribution - `rm dist/*` 4. Create the distribution diff --git a/examples/development_tests/test-version.ipynb b/examples/development_tests/test-version.ipynb new file mode 100644 index 0000000..e918bb6 --- /dev/null +++ b/examples/development_tests/test-version.ipynb @@ -0,0 +1,353 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b89ca528-303e-45a8-abe9-7eb93b0b6825", + "metadata": {}, + "source": [ + "# Test version compatibility\n", + "\n", + "Covers all logical branches of `_confirm_version` and its helper functions, across different server configurations." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "befc8619", + "metadata": {}, + "outputs": [], + "source": [ + "from firefly_client import FireflyClient\n", + "\n", + "# only needed for the mock scenario\n", + "from firefly_client._server_compat import is_server_compatible\n", + "from unittest.mock import patch, MagicMock" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cf4a4e98", + "metadata": {}, + "outputs": [], + "source": [ + "# Uncomment for debugging outputs\n", + "FireflyClient._debug = True" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "3567a13e", + "metadata": {}, + "outputs": [], + "source": [ + "def pprint_confirm_version(result):\n", + " \"\"\"For pretty-printing the result of _confirm_version() for debugging.\"\"\"\n", + " print(f\"reachable: {result['reachable']}\")\n", + " print(f\"compatible: {result['compatible']}\")\n", + " print(f\"server_version: {result['server_version']!r}\")\n", + " try:\n", + " raw = result['response'].json()\n", + " except Exception:\n", + " raw = result['response'].text[:200]\n", + " print(f\"raw response: {raw}\")" + ] + }, + { + "cell_type": "markdown", + "id": "dcc284a4", + "metadata": {}, + "source": [ + "## Compatible server\n", + "Creating a FireflyClient instance should succeed without warnings or errors." + ] + }, + { + "cell_type": "markdown", + "id": "a3000001", + "metadata": {}, + "source": [ + "### Base Firefly app" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "a3000002", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG: new instance: http://localhost:8080/firefly\n" + ] + } + ], + "source": [ + "fc_base = FireflyClient.make_client(url='http://localhost:8080/firefly', launch_browser=False)" + ] + }, + { + "cell_type": "markdown", + "id": "e73b6aaf", + "metadata": {}, + "source": [ + "`_confirm_version()` should return `reachable=True` and `compatible=True` and note that the server version is under the `Version` key." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6c97d585", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "reachable: True\n", + "compatible: True\n", + "server_version: '2026.1-DEV:FIREFLY-1331-version-validation_c5e3'\n", + "raw response: {'Version': '2026.1-DEV:FIREFLY-1331-version-validation_c5e3', 'Built On': 'Tue Apr 07 14:25:15 PDT 2026', 'Git commit': 'c5e3756bf'}\n" + ] + } + ], + "source": [ + "pprint_confirm_version(fc_base._confirm_version())" + ] + }, + { + "cell_type": "markdown", + "id": "afaef722", + "metadata": {}, + "source": [ + "### App using Firefly" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "60fed01a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG: new instance: http://localhost:8080/irsaviewer/\n" + ] + } + ], + "source": [ + "fc_app = FireflyClient.make_client(url='http://localhost:8080/irsaviewer/', launch_browser=False)" + ] + }, + { + "cell_type": "markdown", + "id": "b5d0485d", + "metadata": {}, + "source": [ + "`_confirm_version()` should return `reachable=True` and `compatible=True` and note that the version appears under the `Firefly Library Version` key instead." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8440d219", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "reachable: True\n", + "compatible: True\n", + "server_version: '2026.1-DEV:FIREFLY-1331-version-validation_c5e3'\n", + "raw response: {'Firefly Git Commit': 'c5e3756bf', 'Version': 'v1.0', 'Built On': 'Tue Apr 07 14:25:55 PDT 2026', 'Git commit': '1331d2dd', 'Firefly Library Version': '2026.1-DEV:FIREFLY-1331-version-validation_c5e3'}\n" + ] + } + ], + "source": [ + "pprint_confirm_version(fc_app._confirm_version())" + ] + }, + { + "cell_type": "markdown", + "id": "a4000001", + "metadata": {}, + "source": [ + "## Incompatible server version\n", + "\n", + "Intercept the version endpoint response of the local irsaviewer and substitute an old version string, then verify that creating a FireflyClient instance raises `ValueError` with the message how to rectify the issue.\n", + "\n", + "We don't have such a server available yet so we'll just mock the response of `_confirm_version` to simulate this scenario." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c709b81c", + "metadata": {}, + "outputs": [], + "source": [ + "INCOMPATIBLE_VERSION = '2025.5.5' # below the MIN_SERVER_VERSION (2025.6-DEV)\n", + "\n", + "mock_resp_incompat = MagicMock()\n", + "mock_resp_incompat.status_code = 200\n", + "mock_resp_incompat.json.return_value = {'Version': INCOMPATIBLE_VERSION}\n", + "\n", + "ver_incompat = {\n", + " 'reachable': True,\n", + " 'compatible': is_server_compatible(INCOMPATIBLE_VERSION),\n", + " 'server_version': INCOMPATIBLE_VERSION,\n", + " 'response': mock_resp_incompat,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a4000003", + "metadata": {}, + "outputs": [ + { + "ename": "ValueError", + "evalue": "Version of the provided Firefly server http://localhost:8080/firefly/ is not compatible with this version of firefly_client.\n Server version: 2025.5.5\n Required: >=2026.1-DEV\n Please use the URL of a compatible Firefly server\n", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mValueError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[9]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[38;5;66;03m# Patch _confirm_version at class level so the mock takes effect inside make_client()\u001b[39;00m\n\u001b[32m 2\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m patch.object(FireflyClient, \u001b[33m'\u001b[39m\u001b[33m_confirm_version\u001b[39m\u001b[33m'\u001b[39m, return_value=ver_incompat):\n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[43mFireflyClient\u001b[49m\u001b[43m.\u001b[49m\u001b[43mmake_client\u001b[49m\u001b[43m(\u001b[49m\u001b[43murl\u001b[49m\u001b[43m=\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mhttp://localhost:8080/firefly/\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mlaunch_browser\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43;01mFalse\u001b[39;49;00m\u001b[43m)\u001b[49m\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/dev/cm/firefly_client/firefly_client/firefly_client.py:223\u001b[39m, in \u001b[36mFireflyClient.make_client\u001b[39m\u001b[34m(cls, url, html_file, launch_browser, channel_override, verbose, token, viewer_override)\u001b[39m\n\u001b[32m 175\u001b[39m \u001b[38;5;129m@classmethod\u001b[39m\n\u001b[32m 176\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mmake_client\u001b[39m(\u001b[38;5;28mcls\u001b[39m, url=_default_url, html_file=_def_html_file, launch_browser=\u001b[38;5;28;01mTrue\u001b[39;00m,\n\u001b[32m 177\u001b[39m channel_override=\u001b[38;5;28;01mNone\u001b[39;00m, verbose=\u001b[38;5;28;01mFalse\u001b[39;00m, token=\u001b[38;5;28;01mNone\u001b[39;00m, viewer_override=\u001b[38;5;28;01mNone\u001b[39;00m):\n\u001b[32m 178\u001b[39m \u001b[38;5;250m \u001b[39m\u001b[33;03m\"\"\"\u001b[39;00m\n\u001b[32m 179\u001b[39m \u001b[33;03m Factory method to create a Firefly client in a plain Python, IPython, or\u001b[39;00m\n\u001b[32m 180\u001b[39m \u001b[33;03m notebook session, and attempt to open a display. If a display cannot be\u001b[39;00m\n\u001b[32m (...)\u001b[39m\u001b[32m 221\u001b[39m \u001b[33;03m A FireflyClient that works in the lab environment\u001b[39;00m\n\u001b[32m 222\u001b[39m \u001b[33;03m \"\"\"\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m223\u001b[39m fc = \u001b[38;5;28;43mcls\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43murl\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mEnv\u001b[49m\u001b[43m.\u001b[49m\u001b[43mresolve_client_channel\u001b[49m\u001b[43m(\u001b[49m\u001b[43mchannel_override\u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mhtml_file\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mtoken\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mviewer_override\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 224\u001b[39m verbose \u001b[38;5;129;01mand\u001b[39;00m Env.show_start_browser_tab_msg(fc.get_firefly_url())\n\u001b[32m 225\u001b[39m launch_browser \u001b[38;5;129;01mand\u001b[39;00m fc.launch_browser()\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/dev/cm/firefly_client/firefly_client/firefly_client.py:275\u001b[39m, in \u001b[36mFireflyClient.__init__\u001b[39m\u001b[34m(self, url, channel, html_file, token, viewer_override)\u001b[39m\n\u001b[32m 273\u001b[39m debug(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mFirefly server\u001b[39m\u001b[38;5;130;01m\\'\u001b[39;00m\u001b[33ms version endpoint response: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mver[\u001b[33m\"\u001b[39m\u001b[33mresponse\u001b[39m\u001b[33m\"\u001b[39m].json()\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m'\u001b[39m)\n\u001b[32m 274\u001b[39m \u001b[38;5;28;01melif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m ver[\u001b[33m'\u001b[39m\u001b[33mcompatible\u001b[39m\u001b[33m'\u001b[39m]:\n\u001b[32m--> \u001b[39m\u001b[32m275\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\n\u001b[32m 276\u001b[39m \u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mVersion of the provided Firefly server \u001b[39m\u001b[38;5;132;01m{\u001b[39;00murl\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m is not compatible with this version of firefly_client.\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 277\u001b[39m \u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33m Server version: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mver[\u001b[33m\"\u001b[39m\u001b[33mserver_version\u001b[39m\u001b[33m\"\u001b[39m]\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 278\u001b[39m \u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33m Required: >=\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mMIN_SERVER_VERSION\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 279\u001b[39m \u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33m Please use the URL of a compatible Firefly server\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 280\u001b[39m )\n\u001b[32m 282\u001b[39m debug(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mnew instance: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00murl\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m'\u001b[39m)\n", + "\u001b[31mValueError\u001b[39m: Version of the provided Firefly server http://localhost:8080/firefly/ is not compatible with this version of firefly_client.\n Server version: 2025.5.5\n Required: >=2026.1-DEV\n Please use the URL of a compatible Firefly server\n" + ] + } + ], + "source": [ + "# Patch _confirm_version at class level so the mock takes effect inside make_client()\n", + "with patch.object(FireflyClient, '_confirm_version', return_value=ver_incompat):\n", + " FireflyClient.make_client(url='http://localhost:8080/firefly/', launch_browser=False)" + ] + }, + { + "cell_type": "markdown", + "id": "7eaed749", + "metadata": {}, + "source": [ + "`_confirm_version()` should return `reachable=True` and `compatible=False`." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "a4000002", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "reachable: True\n", + "compatible: False\n", + "server_version: '2025.5.5'\n", + "raw response: {'Version': '2025.5.5'}\n" + ] + } + ], + "source": [ + "pprint_confirm_version(ver_incompat)" + ] + }, + { + "cell_type": "markdown", + "id": "a5000001", + "metadata": {}, + "source": [ + "## Server's Version endpoint not reachable\n", + "\n", + "When the version endpoint returns a non-200 response, creating a FireflyClient instance should emit a warning and proceed rather than raise an error." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "a5000004", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: Could not retrieve version of the Firefly server https://irsa.ipac.caltech.edu/irsaviewer/. Proceeding without compatibility check.\n", + "DEBUG: Firefly server's version endpoint response: {'success': False, 'error': {}}\n", + "DEBUG: new instance: https://irsa.ipac.caltech.edu/irsaviewer/\n" + ] + } + ], + "source": [ + "fc_no_version = FireflyClient.make_client(url='https://irsa.ipac.caltech.edu/irsaviewer/', launch_browser=False)" + ] + }, + { + "cell_type": "markdown", + "id": "9a3c4582", + "metadata": {}, + "source": [ + "`_confirm_version()` should return `reachable=False` and hence `compatible=False` and `server_version=None`." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "8f14c22b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "reachable: False\n", + "compatible: False\n", + "server_version: None\n", + "raw response: {'success': False, 'error': {}}\n" + ] + } + ], + "source": [ + "pprint_confirm_version(fc_no_version._confirm_version())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b30f97cd", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ffpy", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/firefly_client/_server_compat.py b/firefly_client/_server_compat.py new file mode 100644 index 0000000..45313fb --- /dev/null +++ b/firefly_client/_server_compat.py @@ -0,0 +1,45 @@ +from packaging.version import InvalidVersion, Version + + +# Minimum version of Firefly server that this version of firefly_client is compatible with +# Must be updated when a new release of firefly_client is made that relies on updates in Firefly server. +# For DEV version of Firefly server, the branch and commit info isn't considered in comparison so can be omitted. +MIN_SERVER_VERSION = '2026.1-DEV' # minimum bound on DEV includes PRE and formal releases + +FIREFLY_LIB_VERSION_KEY = 'Firefly Library Version' +APP_VERSION_KEY = 'Version' + + +def get_server_version(version_data: dict) -> str|None: + version = version_data.get(FIREFLY_LIB_VERSION_KEY) + if not version: # Firefly isn't used as library but is the base app + version = version_data.get(APP_VERSION_KEY) + + return version + + +def standardize_version(firefly_version_str: str) -> Version|None: + """Convert a Firefly server version string to a Version object for comparison. + Returns None if the string is not parseable. + """ + try: + return Version(firefly_version_str) + except InvalidVersion: + if 'DEV' in firefly_version_str: + # Firefly version strings after 'DEV' may contain non-standard commit/branch info, + # e.g. '2024.1-DEV_abc1' or '2024.1-DEV:branch_abc1'. Strip the suffix to make it parseable. + try: + return Version(firefly_version_str.partition('DEV')[0] + 'DEV') + except InvalidVersion: + pass + return None + + +_MIN_VERSION = standardize_version(MIN_SERVER_VERSION) + + +def is_server_compatible(server_version: str|None) -> bool: + if not server_version: + return False + version = standardize_version(server_version) + return version is not None and version >= _MIN_VERSION diff --git a/firefly_client/firefly_client.py b/firefly_client/firefly_client.py index 549f8b4..9e0e9bc 100644 --- a/firefly_client/firefly_client.py +++ b/firefly_client/firefly_client.py @@ -36,6 +36,10 @@ except ImportError: from fc_utils import debug, warn, dict_to_str, create_image_url, ensure3, gen_item_id,\ DebugMarker, ALL, ACTION_DICT, LO_VIEW_DICT +try: + from ._server_compat import MIN_SERVER_VERSION, get_server_version, is_server_compatible +except ImportError: + from _server_compat import MIN_SERVER_VERSION, get_server_version, is_server_compatible __docformat__ = 'restructuredtext' _def_html_file = Env.find_default_firefly_html() @@ -238,8 +242,8 @@ def __init__(self, url, channel, html_file=_def_html_file, token=None, viewer_ov # urls for cmd service and browser protocol = 'https' if ssl else 'http' - self.url_cmd_service = urljoin('{}://{}/'.format(protocol, self.location), 'sticky/CmdSrv') - self.url_browser = urljoin(urljoin('{}://{}/'.format(protocol, self.location), html_file), '?__wsch=') + self.url_cmd_service = urljoin(f'{protocol}://{self.location}/', 'CmdSrv/sync') + self.url_browser = urljoin(urljoin(f'{protocol}://{self.location}/', html_file), '?__wsch=') self.url_bw = self.url_browser # keep around for backward compatibility self.session = requests.Session() @@ -262,6 +266,19 @@ def __init__(self, url, channel, html_file=_def_html_file, token=None, viewer_ov 'the `token` parameter must be passed.' ) raise ValueError(f'{url_err_msg}\n\n{token_err_msg}') + + ver = self._confirm_version() + if not ver['reachable']: + warn(f'Could not retrieve version of the Firefly server {url}. Proceeding without compatibility check.') + debug(f'Firefly server\'s version endpoint response: {ver["response"].json()}') + elif not ver['compatible']: + raise ValueError( + f'Version of the provided Firefly server {url} is not compatible with this version of firefly_client.\n' + f' Server version: {ver["server_version"]}\n' + f' Required: >={MIN_SERVER_VERSION}\n' + f' Please use the URL of a compatible Firefly server\n' + ) + debug(f'new instance: {url}') def _lab_env_tab_start(self, tab_type, html_file): @@ -303,6 +320,21 @@ def confirm_access(url, token=None): response = requests.get(healthz_url, headers=headers, allow_redirects=False) return {'success': response.status_code == 200, 'response': response} + def _confirm_version(self): + version_url = f'{self.url_cmd_service}?cmd=CmdVersion' + server_response = self.session.get(version_url, headers=self.header_from_ws) + + reachable = server_response.status_code == 200 + server_version = get_server_version(server_response.json()) if reachable else None + compatible = is_server_compatible(server_version) + + return { + 'reachable': reachable, + 'compatible': compatible, + 'server_version': server_version, + 'response': server_response, + } + def _send_url_as_get(self, url): return self.call_response(self.session.get(url, headers=self.header_from_ws)) diff --git a/pyproject.toml b/pyproject.toml index 57ddb0b..88ecf38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,8 @@ license = {file = "License.txt"} requires-python = ">=3.10" dependencies = [ "websocket-client", - "requests" + "requests", + "packaging", ] keywords = [ "jupyter", @@ -44,6 +45,9 @@ Documentation = "https://caltech-ipac.github.io/firefly_client" Repository = "http://github.com/Caltech-IPAC/firefly_client.git" [project.optional-dependencies] +tests = [ + "pytest", +] docs = [ "Sphinx>=7.3,<8.0", "sphinx-automodapi", diff --git a/tests/test_server_compat.py b/tests/test_server_compat.py new file mode 100644 index 0000000..12137b9 --- /dev/null +++ b/tests/test_server_compat.py @@ -0,0 +1,49 @@ +import pytest +from unittest.mock import patch +from firefly_client._server_compat import ( + get_server_version, standardize_version, is_server_compatible +) + + +@pytest.mark.parametrize('data,expected', [ + ({'Firefly Library Version': '2026.1-DEV:branch_abc'}, '2026.1-DEV:branch_abc'), # library mode + ({'Version': '2026.1-DEV:branch_abc'}, '2026.1-DEV:branch_abc'), # app mode + ({'Firefly Library Version': '2026.1', 'Version': '2025.1'}, '2026.1'), # library key wins + ({}, None), # missing both keys +]) +def test_get_server_version(data, expected): + assert get_server_version(data) == expected + + +@pytest.mark.parametrize('ver,expected_str', [ + ('2026.1', '2026.1'), # standard release + ('2026.1-DEV', '2026.1.dev0'), # plain DEV + ('2026.1-DEV:branch_abc1', '2026.1.dev0'), # DEV with branch:commit suffix + ('2024.1-DEV_abc1', '2024.1.dev0'), # DEV with underscore suffix + ('2026.1-PRE-3', '2026.1rc3'), # PRE with number + ('2026.1-PRE', '2026.1rc0'), # PRE without number + ('not_a_version', 'None'), # unparseable +]) +def test_standardize_version(ver, expected_str): + assert str(standardize_version(ver)) == expected_str + + +# Locked minimum version for test_is_server_compatible since test cases are based on this +_FIXED_MIN_VERSION = standardize_version('2026.1-DEV') + + +@pytest.mark.parametrize('ver,expected', [ + ('2026.1-DEV', True), # exact minimum + ('2026.1-DEV:branch_abc', True), # DEV with suffix at minimum version + ('2026.1-PRE-3', True), # pre-release of same version cycle + ('2026.1', True), # formal release >= minimum + ('2027.1', True), # clearly newer + ('2025.6-PRE-3', False), # pre-release of older version cycle + ('2025.6', False), # below minimum + ('2024.1-DEV_abc1', False), # old DEV + ('not_a_version', False), # unparseable + (None, False), # None +]) +def test_is_server_compatible(ver, expected): + with patch('firefly_client._server_compat._MIN_VERSION', _FIXED_MIN_VERSION): + assert is_server_compatible(ver) == expected