Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/development/guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <https://github.com/Caltech-IPAC/firefly_client/tree/master/examples/development_tests>`_ 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 <https://github.com/Caltech-IPAC/firefly_client/tree/master/examples/development_tests>`_ of the firefly_client GitHub repository.
4 changes: 3 additions & 1 deletion docs/development/new-release-procedure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 Firefly server changes that would **break client API** without them (not just behavioral improvements), **wait** until a Firefly release is made. Then raise `MIN_SERVER_VERSION` in `firefly_client/_server_compat.py` to that Firefly release version, and add an entry to the dependency log in the same file.
3. Clean out old distribution
- `rm dist/*`
4. Create the distribution
Expand Down
283 changes: 283 additions & 0 deletions examples/development_tests/test-version.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
{
"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, FIREFLY_VERSION_KEY\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\"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 errors."
]
},
{
"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 = FireflyClient.make_client(url='http://localhost:8080/firefly', launch_browser=False)"
]
},
{
"cell_type": "markdown",
"id": "e73b6aaf",
"metadata": {},
"source": [
"`_confirm_version()` should return `compatible=True` and the server version string from the `Firefly Version` field in the response `data`."
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "6c97d585",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"compatible: True\n",
"server_version: '2026.1-DEV:FIREFLY-1331-version-validation_f9ee'\n",
"raw response: {'success': True, 'data': {'Application Version': '2026.1-DEV:FIREFLY-1331-version-validation_f9ee', 'Built On': 'Wed Apr 29 13:25:44 PDT 2026', 'Git commit': 'f9ee5f1b6', 'Firefly Version': '2026.1-DEV:FIREFLY-1331-version-validation_f9ee'}}\n"
]
}
],
"source": [
"pprint_confirm_version(fc._confirm_version())"
]
},
{
"cell_type": "markdown",
"id": "a4000001",
"metadata": {},
"source": [
"## Incompatible server version\n",
"\n",
"Intercept the version endpoint response and substitute an old version string, then verify that creating a FireflyClient instance raises `ValueError` with a message explaining how to resolve the issue.\n",
"\n",
"We don't have such a server available, so we mock `_confirm_version` to simulate this scenario."
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "c709b81c",
"metadata": {},
"outputs": [],
"source": [
"INCOMPATIBLE_VERSION = '2025.3.2' # below MIN_SERVER_VERSION (2025.4)\n",
"\n",
"mock_resp_incompat = MagicMock()\n",
"mock_resp_incompat.status_code = 200\n",
"mock_resp_incompat.json.return_value = {'success': True, 'data': {FIREFLY_VERSION_KEY: INCOMPATIBLE_VERSION}}\n",
"\n",
"ver_incompat = {\n",
" 'compatible': is_server_compatible(INCOMPATIBLE_VERSION),\n",
" 'server_version': INCOMPATIBLE_VERSION,\n",
" 'response': mock_resp_incompat,\n",
"}"
]
},
{
"cell_type": "code",
"execution_count": 7,
"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.3.2\n Required: >=2025.4\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[7]\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:272\u001b[39m, in \u001b[36mFireflyClient.__init__\u001b[39m\u001b[34m(self, url, channel, html_file, token, viewer_override)\u001b[39m\n\u001b[32m 270\u001b[39m ver = \u001b[38;5;28mself\u001b[39m._confirm_version()\n\u001b[32m 271\u001b[39m \u001b[38;5;28;01mif\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[32m272\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\n\u001b[32m 273\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 274\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 275\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 276\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 277\u001b[39m )\n\u001b[32m 279\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.3.2\n Required: >=2025.4\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 `compatible=False` and the old version string."
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "a4000002",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"compatible: False\n",
"server_version: '2025.3.2'\n",
"raw response: {'success': True, 'data': {'Firefly Version': '2025.3.2'}}\n"
]
}
],
"source": [
"pprint_confirm_version(ver_incompat)"
]
},
{
"cell_type": "markdown",
"id": "a5000001",
"metadata": {},
"source": [
"## Version unknown\n",
"\n",
"When the version endpoint returns `success: false`, is unreachable, or the version string is absent from the response, `_confirm_version()` falls back to `compatible=True` to preserve backward compatibility with servers that predate the version endpoint. Creating a FireflyClient instance should succeed."
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "a5000004",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"DEBUG: new instance: https://irsa.ipac.caltech.edu/irsaviewer/\n"
]
}
],
"source": [
"fc_old = FireflyClient.make_client(url='https://irsa.ipac.caltech.edu/irsaviewer/', launch_browser=False)"
]
},
{
"cell_type": "markdown",
"id": "9a3c4582",
"metadata": {},
"source": [
"`_confirm_version()` should return `compatible=True` and `server_version=None`."
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "8f14c22b",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"compatible: True\n",
"server_version: None\n",
"raw response: {'success': False, 'error': {}}\n"
]
}
],
"source": [
"pprint_confirm_version(fc_old._confirm_version())"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f6fda662",
"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
}
36 changes: 36 additions & 0 deletions firefly_client/_server_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Minimum Firefly server version this firefly_client is compatible with.
# Bump this only when a client release depends on a server-side change that
# breaks client functionality without it. Behavioral improvements don't count.
#
# Recent PR dependency log — explaining the dependency and whether it is an API break:
# firefly_client#79 → firefly#1936 (2026.1): _confirm_version() needs version endpoint [still works without it]
# firefly_client#78 → firefly#1910 (2026.1): show_xyplot/chart() needs activating chart view [still works without it]
# firefly_client#75 → firefly#1825 (2025.4): show_data() needs external upload action [fails without it] ← CURRENT
MIN_SERVER_VERSION = '2025.4'

FIREFLY_VERSION_KEY = 'Firefly Version'


def _parse_version(version_str: str) -> tuple[int, int] | None:
"""Extract (major, minor) from a Firefly version string, ignoring DEV/PRE/patch suffixes.
Returns None if not parseable.
"""
try:
core = version_str.split('-')[0] # strip DEV/PRE/patch suffix
parts = core.split('.')
return (int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None


def is_server_compatible(server_version: str | None) -> bool:
if not server_version:
return True # unknown version — pass through for backward compatibility

parsed_server_version = _parse_version(server_version)
if parsed_server_version is None:
return True # unparseable version — pass through

# Python tuples are compared lexicographically (element-by-element), so (2026, 1) >= (2025, 4)
# evaluates as: 2026 > 2025 → True, without needing to inspect the minor at all
return parsed_server_version >= _parse_version(MIN_SERVER_VERSION)
Loading
Loading