From 303277a68c7e039b6207b6c85e9ec336e4a9e951 Mon Sep 17 00:00:00 2001 From: Sheimy Paz Serpa Date: Fri, 17 Jul 2026 16:08:25 -0400 Subject: [PATCH 1/8] fermi: migrate from LATDataQuery.cgi scraping to the new REST API --- CHANGES.rst | 22 ++ astroquery/fermi/__init__.py | 23 +- astroquery/fermi/core.py | 374 +++++++++++++----- astroquery/fermi/tests/__init__.py | 1 + .../fermi/tests/data/query_result_m31.html | 136 ------- .../fermi/tests/data/query_results.json | 7 + .../tests/data/query_status_complete.json | 14 + .../fermi/tests/data/query_status_failed.json | 7 + .../tests/data/query_status_running.json | 14 + astroquery/fermi/tests/data/query_submit.json | 6 + .../fermi/tests/data/result_page_m31.html | 138 ------- astroquery/fermi/tests/test_fermi.py | 196 +++++++-- astroquery/fermi/tests/test_fermi_remote.py | 43 +- docs/fermi/fermi.rst | 38 +- 14 files changed, 594 insertions(+), 425 deletions(-) delete mode 100644 astroquery/fermi/tests/data/query_result_m31.html create mode 100644 astroquery/fermi/tests/data/query_results.json create mode 100644 astroquery/fermi/tests/data/query_status_complete.json create mode 100644 astroquery/fermi/tests/data/query_status_failed.json create mode 100644 astroquery/fermi/tests/data/query_status_running.json create mode 100644 astroquery/fermi/tests/data/query_submit.json delete mode 100644 astroquery/fermi/tests/data/result_page_m31.html diff --git a/CHANGES.rst b/CHANGES.rst index c6158ea041..1514755ace 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -72,6 +72,28 @@ vizier - Methods ``get_catalog``, ``get_catalog_async`` and ``query_*`` now always return UCD1+ instead of UCD1. [#3458] +fermi +^^^^^ + +- The module now uses the new Fermi LAT Data Query REST API + (``/ssc/data/access/lat/query/api/v1``), which replaces the retired + ``LATDataQuery.cgi`` endpoint whose HTML responses were previously scraped + with regular expressions. The user-facing ``FermiLAT.query_object()`` + signature is unchanged. [#XXXX] +- ``FermiLAT.query_object_async()`` now returns the server-assigned + ``query_id`` instead of the URL of an HTML results page. [#XXXX] +- New methods ``FermiLAT.get_status()``, ``FermiLAT.list_results()``, + ``FermiLAT.wait_for_completion()`` and ``FermiLAT.get_file_urls()`` expose + the individual steps of the asynchronous query workflow. [#XXXX] +- New keyword arguments: ``zenithangle`` (maximum zenith angle in degrees) and + ``coordsystem`` (``'J2000'``, ``'B1950'`` or ``'Galactic'``). All-sky + queries (radius > 60 deg, observation window <= 24 h) are now supported. [#XXXX] +- ``GetFermilatDatafile`` and ``get_fermilat_datafile`` are deprecated; they + now delegate to ``FermiLAT.get_file_urls()`` and take a ``query_id`` rather + than a results-page URL. [#XXXX] +- The module no longer emits an "Experimental" warning on import. [#XXXX] + + mast ^^^^ - ``utils.mast_relative_path`` is now deprecated in favor of ``utils.get_cloud_paths``. [#3488] diff --git a/astroquery/fermi/__init__.py b/astroquery/fermi/__init__.py index 8db47889ca..97eedef047 100644 --- a/astroquery/fermi/__init__.py +++ b/astroquery/fermi/__init__.py @@ -4,6 +4,10 @@ https://fermi.gsfc.nasa.gov https://fermi.gsfc.nasa.gov/ssc/data/ + +As of 2026, the Fermi LAT Data Server exposes a JSON REST API. This module +targets that API; the legacy ``LATDataQuery.cgi`` HTML-scraping path has been +removed. """ from astropy import config as _config @@ -14,11 +18,18 @@ class Conf(_config.ConfigNamespace): """ url = _config.ConfigItem( - 'https://fermi.gsfc.nasa.gov/cgi-bin/ssc/LAT/LATDataQuery.cgi', - 'Fermi query URL.') + 'https://fermi.gsfc.nasa.gov/ssc/data/access/lat/query/api/v1', + 'Base URL of the Fermi LAT Data Query REST API. Endpoints ' + '(/query, /query/{id}/status, /query/{id}/results) are appended to it.') + file_base_url = _config.ConfigItem( + # NOTE: the results endpoint returns bare filenames; download URLs are + # reconstructed against this staging area. Currently a /test/ path - + # revisit if the staging location changes at production cutover. + 'https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/test/queries/', + 'Base URL under which query result files are staged.') timeout = _config.ConfigItem( 60, - 'Time limit for connecting to Fermi server.') + 'Time limit for connecting to the Fermi server.') retrieval_timeout = _config.ConfigItem( 120, 'Time limit for retrieving a data file once it has been located.') @@ -26,13 +37,9 @@ class Conf(_config.ConfigNamespace): conf = Conf() -from .core import FermiLAT, FermiLATClass, GetFermilatDatafile, get_fermilat_datafile +from .core import FermiLAT, FermiLATClass, GetFermilatDatafile, get_fermilat_datafile # noqa: E402 __all__ = ['FermiLAT', 'FermiLATClass', 'GetFermilatDatafile', 'get_fermilat_datafile', 'Conf', 'conf', ] - -import warnings -warnings.warn("Experimental: Fermi-LAT has not yet been refactored to have " - "its API match the rest of astroquery.") diff --git a/astroquery/fermi/core.py b/astroquery/fermi/core.py index 284678d382..40612b2f8d 100644 --- a/astroquery/fermi/core.py +++ b/astroquery/fermi/core.py @@ -1,170 +1,358 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst -"""Download of Fermi LAT (Large Area Telescope) data""" +"""Download of Fermi LAT (Large Area Telescope) data. + +This module talks to the Fermi LAT Data Query REST API:: + + POST /query -> {"query_id": ...} + GET /query/{id}/status -> {"state": ...} + GET /query/{id}/results -> {"files": [{"name": ...}, ...]} + +which replaced the legacy ``LATDataQuery.cgi`` form endpoint whose HTML +response pages this module used to scrape with regular expressions. +""" import re -import requests import time +from urllib.parse import urljoin + import astropy.units as u +from astropy.utils.decorators import deprecated + from ..query import BaseQuery from ..utils import commons, async_to_sync +from ..exceptions import RemoteServiceError, TimeoutError from . import conf __all__ = ['FermiLAT', 'FermiLATClass', 'GetFermilatDatafile', 'get_fermilat_datafile', ] +# The API reports free-form, human-readable states ("Query completed", +# "Query in progress", ...). Rather than hard-coding the exact strings - +# which is what made the previous scraping implementation so brittle - we +# match on substrings and treat anything unrecognised as "still running". +_DONE_TOKENS = ('complet', 'done', 'finished', 'ready') +_ERROR_TOKENS = ('error', 'fail', 'reject', 'invalid', 'abort', 'cancel') + +# "128.836,-45.1764" and friends: already a coordinate pair, no name +# resolution needed (also covers the all-sky "0.0,0.0" convention). +_COORD_PAIR_RE = re.compile(r'^\s*[-+]?\d+(\.\d*)?\s*,\s*[-+]?\d+(\.\d*)?\s*$') + @async_to_sync class FermiLATClass(BaseQuery): """ - TODO: document + Query the Fermi LAT Data Server. + + The server runs queries asynchronously: `query_object_async` submits a + query and returns its ``query_id``, and `query_object` additionally waits + for the query to finish and returns the URLs of the staged data files. """ - request_url = conf.url - result_url_re = re.compile(r'The results of your query may be found at ' - r' 60 (typically 180) + for an all-sky query - note that all-sky queries are restricted by + the server to observation windows of <= 24 hours. + .. warning:: - Defaults to 1 degree if left blank + Defaults to 1 degree if left blank. obsdates : str - Observation dates. - timesys: 'Gregorian' or 'MET' or 'MJD' - Time system associated with obsdates - energyrange_MeV: str - Energy range in MeV + Observation window, as ``"start,stop"``. + timesys : 'Gregorian' or 'MET' or 'MJD' + Time system associated with ``obsdates``. + energyrange_MeV : str + Energy range in MeV, as ``"min,max"``. + LATdatatype : 'Photon' or 'Extended' or 'None' + Which LAT event class to retrieve. + spacecraftdata : bool + Whether to also retrieve the spacecraft (SC) file. + coordsystem : 'J2000' or 'B1950' or 'Galactic' + Coordinate system of the submitted coordinates. Resolved names + and `~astropy.coordinates.SkyCoord` inputs are transformed into + this frame. + zenithangle : float, optional + Maximum zenith angle in degrees. The server default is 180. Returns ------- - payload_dict : Requests payload in a dictionary + payload : dict + The JSON payload posted to ``/query``. """ - - payload = {'shapefield': str(searchradius), - 'coordsystem': 'J2000', - 'coordfield': _parse_coordinates(name_or_coords), - 'destination': 'query', - 'timefield': obsdates, - 'timetype': timesys, - 'energyfield': energyrange_MeV, - 'photonOrExtendedOrNone': LATdatatype, - 'spacecraft': 'on' if spacecraftdata else 'off'} + # The API requires a radius; the CGI form silently defaulted to 1 deg, + # so preserve that behaviour rather than sending an empty string. + if searchradius in ('', None): + searchradius = 1 + + payload = { + 'coordfield': _parse_coordinates(name_or_coords, + coordsystem=coordsystem), + 'coordsystem': coordsystem, + 'radius': searchradius, + 'timefield': obsdates, + 'timetype': timesys, + 'energyfield': energyrange_MeV, + 'photonOrExtendedOrNone': LATdatatype, + 'spacecraft': 'on' if spacecraftdata else 'off', + } + + if zenithangle is not None: + payload['zenithangle'] = zenithangle return payload - def _parse_result(self, result, *, verbose=False, **kwargs): + # ------------------------------------------------------------------ + # status / results + # ------------------------------------------------------------------ + def get_status(self, query_id): """ - Use get_fermilat_datafile to download a result URL + Return the raw status document for ``query_id``. + + Returns + ------- + status : dict + The parsed ``/query/{id}/status`` response. The overall state is + in ``status['state']``; per-server detail is in ``queue_status``, + ``running_status`` and ``servers_status``. """ - return get_fermilat_datafile(result) + response = self._request( + "GET", url=f"{self.base_url}/query/{query_id}/status", + timeout=self.TIMEOUT, cache=False) + response.raise_for_status() + return response.json() + def list_results(self, query_id): + """ + Return the file metadata for a completed query. -FermiLAT = FermiLATClass() + Returns + ------- + files : list of dict + The ``files`` entries of the ``/query/{id}/results`` response. + """ + response = self._request( + "GET", url=f"{self.base_url}/query/{query_id}/results", + timeout=self.RETRIEVAL_TIMEOUT, cache=False) + response.raise_for_status() + return response.json().get('files', []) + + def wait_for_completion(self, query_id, *, check_frequency=None, + max_wait=None, verbose=False): + """ + Poll ``/query/{id}/status`` until the query finishes. + Parameters + ---------- + check_frequency : float, optional + Minutes between polls. Defaults to `check_frequency`. + max_wait : float, optional + Give up after this many minutes. ``None`` (default) waits + indefinitely. + verbose : bool + Print the elapsed time on completion. -def _parse_coordinates(coordinates): - try: - c = commons.parse_coordinates(coordinates) - # now c has some subclass of astropy.coordinate - # get ra, dec and frame - return _fermi_format_coords(c) - except (u.UnitsError, TypeError): - raise Exception("Coordinates not specified correctly") + Returns + ------- + status : dict + The final status document. + """ + if check_frequency is None: + check_frequency = self.check_frequency + elapsed_time = 0.0 -def _fermi_format_coords(c): - c = c.transform_to('fk5') - return "{0:0.5f},{1:0.5f}".format(c.ra.degree, c.dec.degree) + while True: + status = self.get_status(query_id) + state = str(status.get('state', '')) + lowered = state.lower() + if any(token in lowered for token in _ERROR_TOKENS): + raise RemoteServiceError( + f"Fermi LAT query {query_id} failed with state {state!r}") -class GetFermilatDatafile: + if any(token in lowered for token in _DONE_TOKENS): + if verbose: + print(f"Query completed in {elapsed_time:0.1f} minutes") + return status + + if max_wait is not None and elapsed_time >= max_wait: + raise TimeoutError( + f"Fermi LAT query {query_id} did not complete within " + f"{max_wait} minutes (last state: {state!r})") + + time.sleep(check_frequency * 60) + elapsed_time += check_frequency + + def get_file_urls(self, query_id, *, check_frequency=None, max_wait=None, + verbose=False): + """ + Wait for ``query_id`` to complete and return the result file URLs. + + Returns + ------- + urls : list of str + """ + self.wait_for_completion(query_id, check_frequency=check_frequency, + max_wait=max_wait, verbose=verbose) + return [_file_url(entry) for entry in self.list_results(query_id)] + + def _parse_result(self, result, *, verbose=False, **kwargs): + """ + Turn the ``query_id`` returned by `query_object_async` into a list of + downloadable file URLs, waiting for the query to complete. + """ + return self.get_file_urls(result, verbose=verbose) + + +FermiLAT = FermiLATClass() + + +def _file_url(entry): """ - TODO: document - TODO: Fail with useful failure messages on genuine failures - (this doesn't need to be implemented as a class) + Build an absolute URL for one entry of the ``results`` file list. + + The API is documented as returning ``{"name": "..._PH00.fits"}``; if a + future revision also returns an absolute ``url``/``href``/``path``, prefer + that over reconstructing the location from `conf.file_base_url`. """ + if isinstance(entry, str): + name = entry + else: + for key in ('url', 'href', 'path', 'location'): + value = entry.get(key) + if value: + if value.startswith('http'): + return value + return urljoin(conf.file_base_url, value.lstrip('/')) + name = entry.get('name') + + if not name: + raise RemoteServiceError( + f"Could not determine a file URL from results entry {entry!r}") + + return urljoin(conf.file_base_url, name) + + +def _parse_coordinates(coordinates, *, coordsystem='J2000'): + # A literal "RA,Dec" pair (including the all-sky "0.0,0.0" convention) is + # passed straight through - no name resolution, no frame transform. + if isinstance(coordinates, str) and _COORD_PAIR_RE.match(coordinates): + return coordinates.replace(' ', '') - fitsfile_re = re.compile(r'Available') - fitsfile_re = re.compile(r'wget (https://fermi\.gsfc\.nasa\.gov/FTP/fermi/data/lat/queries/[A-Za-z0-9_]*.fits)') - # wget https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L1809182001077FA3883F37_SC00.fits + try: + c = commons.parse_coordinates(coordinates) + except (u.UnitsError, TypeError): + raise ValueError("Coordinates not specified correctly") - TIMEOUT = conf.retrieval_timeout + return _fermi_format_coords(c, coordsystem=coordsystem) - check_frequency = 1 # minutes - def __call__(self, result_url, *, check_frequency=1, verbose=False): - self.result_url = result_url +def _fermi_format_coords(c, *, coordsystem='J2000'): + frames = {'j2000': 'fk5', 'b1950': 'fk4', 'galactic': 'galactic'} - page_loaded = False + try: + frame = frames[coordsystem.lower()] + except KeyError: + raise ValueError( + f"Unsupported coordsystem {coordsystem!r}; " + f"expected one of {', '.join(sorted(frames))}") - elapsed_time = 0 + c = c.transform_to(frame) - while not (page_loaded): - page_loaded = fitsfile_urls = self._check_page() - if page_loaded: - # don't wait an extra N minutes for success - break - time.sleep(check_frequency * 60) - elapsed_time += check_frequency - # update progressbar here... + if frame == 'galactic': + lon, lat = c.l.degree, c.b.degree + else: + lon, lat = c.ra.degree, c.dec.degree - if verbose: - print("Query completed in %0.1f minutes" % (elapsed_time)) + return "{0:0.5f},{1:0.5f}".format(lon, lat) - return fitsfile_urls - def _check_page(self): - result_page = requests.post(url=self.result_url, - data=None, - timeout=self.TIMEOUT) +@deprecated(since='0.4.12', + alternative='FermiLAT.query_object or FermiLAT.get_file_urls') +class GetFermilatDatafile: + """ + Deprecated. Retained so that code written against the pre-REST module + keeps importing; it now delegates to `FermiLATClass.get_file_urls`. + """ - pagedata = result_page.text + TIMEOUT = conf.retrieval_timeout + check_frequency = 1 - fitsfile_urls = self.fitsfile_re.findall(pagedata) + def __call__(self, query_id, *, check_frequency=None, verbose=False): + if check_frequency is None: + check_frequency = self.check_frequency + return FermiLAT.get_file_urls(query_id, + check_frequency=check_frequency, + verbose=verbose) - if len(fitsfile_urls) == 0: - return False - else: - return fitsfile_urls +@deprecated(since='0.4.12', + alternative='FermiLAT.query_object or FermiLAT.get_file_urls') +def get_fermilat_datafile(query_id, *, check_frequency=1, verbose=False): + """ + Deprecated. Wait for a query to finish and return its data file URLs. -get_fermilat_datafile = GetFermilatDatafile() + Note that this takes a ``query_id`` - the pre-REST version took the URL of + an HTML results page, which no longer exists. + """ + return FermiLAT.get_file_urls(query_id, check_frequency=check_frequency, + verbose=verbose) diff --git a/astroquery/fermi/tests/__init__.py b/astroquery/fermi/tests/__init__.py index e69de29bb2..9dce85d06f 100644 --- a/astroquery/fermi/tests/__init__.py +++ b/astroquery/fermi/tests/__init__.py @@ -0,0 +1 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst diff --git a/astroquery/fermi/tests/data/query_result_m31.html b/astroquery/fermi/tests/data/query_result_m31.html deleted file mode 100644 index b3334a3050..0000000000 --- a/astroquery/fermi/tests/data/query_result_m31.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -Fermi SSC - LAT Photon, Event and Spacecraft Data - - - - - - - - -
- - - - - - -
-Fermi Science Support Center -
- - - - - - -
- - - -

Query L13090120163429E469B432 submitted.

Please see LAT Data Caveats for important information about Fermi LAT data.

Your search criteria were:

Equatorial coordinates (degrees)(10.6847,41.2687)
Time range (MET)(384196376,399748376)
Time range (Gregorian)(2013-03-05 17:12:56,2013-09-01 17:12:56)
Energy range (MeV)(100,300000)
Search radius (degrees)15

The estimated time for your query to complete is 22 seconds. The results of your query may be found at https://fermi.gsfc.nasa.gov/cgi-bin/ssc/LAT/QueryResults.cgi?id=L13090120163429E469B432.

- - - - - - -
- - - - diff --git a/astroquery/fermi/tests/data/query_results.json b/astroquery/fermi/tests/data/query_results.json new file mode 100644 index 0000000000..62621e0f8c --- /dev/null +++ b/astroquery/fermi/tests/data/query_results.json @@ -0,0 +1,7 @@ +{ + "query_id": "L2601082002167F48EE3069", + "files": [ + { "name": "L2601082002167F48EE3069_PH00.fits" }, + { "name": "L2601082002167F48EE3069_SC00.fits" } + ] +} diff --git a/astroquery/fermi/tests/data/query_status_complete.json b/astroquery/fermi/tests/data/query_status_complete.json new file mode 100644 index 0000000000..2b46480f09 --- /dev/null +++ b/astroquery/fermi/tests/data/query_status_complete.json @@ -0,0 +1,14 @@ +{ + "query_id": "L2601082002167F48EE3069", + "queue_status": [ + { "server": "Photon Server", "queue_rank": 0, "time_remaining": "Unknown" }, + { "server": "Spacecraft Server", "queue_rank": 0, "time_remaining": "Unknown" } + ], + "running_status": [ + { "server": "Photon Server", "status": "Completed", "time_remaining": "N/A" } + ], + "servers_status": [ + { "name": "Photon Server", "position": "Query complete", "time_remaining": "N/A" } + ], + "state": "Query completed" +} diff --git a/astroquery/fermi/tests/data/query_status_failed.json b/astroquery/fermi/tests/data/query_status_failed.json new file mode 100644 index 0000000000..cc3a214b38 --- /dev/null +++ b/astroquery/fermi/tests/data/query_status_failed.json @@ -0,0 +1,7 @@ +{ + "query_id": "L2601082002167F48EE3069", + "queue_status": [], + "running_status": [], + "servers_status": [], + "state": "Query failed" +} diff --git a/astroquery/fermi/tests/data/query_status_running.json b/astroquery/fermi/tests/data/query_status_running.json new file mode 100644 index 0000000000..0f56be7d38 --- /dev/null +++ b/astroquery/fermi/tests/data/query_status_running.json @@ -0,0 +1,14 @@ +{ + "query_id": "L2601082002167F48EE3069", + "queue_status": [ + { "server": "Photon Server", "queue_rank": 2, "time_remaining": "Unknown" }, + { "server": "Spacecraft Server", "queue_rank": 1, "time_remaining": "Unknown" } + ], + "running_status": [ + { "server": "Photon Server", "status": "Running", "time_remaining": "Unknown" } + ], + "servers_status": [ + { "name": "Photon Server", "position": "Query in progress", "time_remaining": "Unknown" } + ], + "state": "Query in progress" +} diff --git a/astroquery/fermi/tests/data/query_submit.json b/astroquery/fermi/tests/data/query_submit.json new file mode 100644 index 0000000000..aa44d969a5 --- /dev/null +++ b/astroquery/fermi/tests/data/query_submit.json @@ -0,0 +1,6 @@ +{ + "query_id": "L2601082002167F48EE3069", + "status": "submitted", + "status_url": "/api/v1/query/L2601082002167F48EE3069/status", + "results_url": "/api/v1/query/L2601082002167F48EE3069/results" +} diff --git a/astroquery/fermi/tests/data/result_page_m31.html b/astroquery/fermi/tests/data/result_page_m31.html deleted file mode 100644 index 7686eb0c33..0000000000 --- a/astroquery/fermi/tests/data/result_page_m31.html +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -Fermi SSC - LAT Photon, Event and Spacecraft Data - - - - - - - - -
- - - - - - -
-Fermi Science Support Center -
- - - - - - -
- - - -

Results for query L13090110364329E469B418

Your search criteria were:

Equatorial coordinates (degrees)(0.18648,0.72028)
Time range (MET)(378691200,378777600)
Time range (Gregorian)(2013-01-01 00:00:00,2013-01-02 00:00:00)
Energy range (MeV)(1000,100000)
Search radius (degrees)15

The state of your query is 2 (Query complete)

Server
Position in Queue
Estimated Time Remaining (sec)
Photon Server
Query complete
N/A
Spacecraft Server
Query complete
N/A

The filenames of the result files consist of the query ID string with an identifier appended to indicate which database the file came from. The identifiers are of the form: _DDNN where DD indicates the database and NN is the file number. The file number will generally be '00' unless the query resulted in a large data volume. In that case the data is broken up into multiple files. The values of the database field are:

  • PH - Photon Database
  • SC - Spacecraft Pointing, Livetime, and History Database
  • EV - Extended Database

In the event that you do not see any files with the data type you requested listed below, you should try resubmitting your query as there may have been a problem.

Filename Number of Entries Size (MB) Status
L13090110364329E469B418_PH00.fits
32
0.03
Available
L13090110364329E469B418_SC00.fits
2436
0.40
Available

If you would like to download the files via wget, simply copy the following commands and paste them into a terminal window. The files will be downloaded to the current directory in the terminal window.

wget https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L13090110364329E469B418_PH00.fits
-wget https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L13090110364329E469B418_SC00.fits
-
- - - - - - -
- - - - diff --git a/astroquery/fermi/tests/test_fermi.py b/astroquery/fermi/tests/test_fermi.py index 0485a7e354..cc6926f988 100644 --- a/astroquery/fermi/tests/test_fermi.py +++ b/astroquery/fermi/tests/test_fermi.py @@ -1,66 +1,186 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst +import json import os -import requests -import pytest + import astropy.coordinates as coord +import pytest + +from astroquery.exceptions import RemoteServiceError from astroquery.utils.mocks import MockResponse from ... import fermi -DATA_FILES = {'async': "query_result_m31.html", - 'result': 'result_page_m31.html', - 'result_url': 'https://fermi.gsfc.nasa.gov/cgi-bin/ssc/LAT/QueryResults.cgi?id=L13090120163429E469B432', - 'fits': ['https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L13090110364329E469B418_PH00.fits', - 'https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L13090110364329E469B418_SC00.fits']} +QUERY_ID = 'L2601082002167F48EE3069' + +DATA_FILES = {'submit': 'query_submit.json', + 'status_running': 'query_status_running.json', + 'status_complete': 'query_status_complete.json', + 'status_failed': 'query_status_failed.json', + 'results': 'query_results.json'} + +EXPECTED_URLS = [ + 'https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/test/queries/' + 'L2601082002167F48EE3069_PH00.fits', + 'https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/test/queries/' + 'L2601082002167F48EE3069_SC00.fits', +] + +FK5_COORDINATES = coord.SkyCoord(10.68471, 41.26875, unit=('deg', 'deg')) def data_path(filename): - data_dir = os.path.join(os.path.dirname(__file__), 'data') - return os.path.join(data_dir, filename) + return os.path.join(os.path.dirname(__file__), 'data', filename) + + +def read_data(key): + with open(data_path(DATA_FILES[key]), 'rb') as fh: + return fh.read() + + +class _Router: + """Dispatch mocked responses on (method, url), like the real API.""" + + def __init__(self, status_key='status_complete'): + self.status_key = status_key + self.calls = [] + + def __call__(self, method, url=None, **kwargs): + self.calls.append((method, url, kwargs)) + + if method == 'POST' and url.endswith('/query'): + return MockResponse(read_data('submit')) + if method == 'GET' and url.endswith(f'/query/{QUERY_ID}/status'): + return MockResponse(read_data(self.status_key)) + if method == 'GET' and url.endswith(f'/query/{QUERY_ID}/results'): + return MockResponse(read_data('results')) + + raise AssertionError(f"unexpected request: {method} {url}") @pytest.fixture -def patch_post(request): +def patch_request(request): mp = request.getfixturevalue("monkeypatch") + router = _Router() + mp.setattr(fermi.FermiLAT, '_request', router) + # don't sleep between status polls + mp.setattr(fermi.FermiLAT, 'check_frequency', 0) + return router - mp.setattr(fermi.FermiLAT, '_request', post_mockreturn) - mp.setattr(requests, 'post', post_mockreturn) - return mp +def test_payload_field_names(): + payload = fermi.core.FermiLAT.query_object_async( + FK5_COORDINATES, searchradius=15, energyrange_MeV='100,300000', + obsdates='772109936,787661936', timesys='MET', + get_query_payload=True) -def post_mockreturn(method="POST", url=None, data=None, timeout=50, **kwargs): - if data is not None: - with open(data_path(DATA_FILES['async']), 'rb') as r: - response = MockResponse(r.read(), **kwargs) - else: - with open(data_path(DATA_FILES['result']), 'rb') as r: - response = MockResponse(r.read(), **kwargs) - return response + assert payload == {'coordfield': '10.68472,41.26875', + 'coordsystem': 'J2000', + 'radius': 15, + 'timefield': '772109936,787661936', + 'timetype': 'MET', + 'energyfield': '100,300000', + 'photonOrExtendedOrNone': 'Photon', + 'spacecraft': 'on'} -FK5_COORDINATES = coord.SkyCoord(10.68471, 41.26875, unit=('deg', 'deg')) +def test_payload_defaults_radius_to_one_degree(): + payload = fermi.core.FermiLAT.query_object_async( + FK5_COORDINATES, get_query_payload=True) + assert payload['radius'] == 1 + + +def test_payload_zenithangle_only_when_given(): + payload = fermi.core.FermiLAT.query_object_async( + FK5_COORDINATES, get_query_payload=True) + assert 'zenithangle' not in payload + + payload = fermi.core.FermiLAT.query_object_async( + FK5_COORDINATES, zenithangle=85, get_query_payload=True) + assert payload['zenithangle'] == 85 -# disable waiting so tests run fast -fermi.core.get_fermilat_datafile.TIMEOUT = 1 -fermi.core.get_fermilat_datafile.check_frequency = 0 +def test_payload_allsky_coordinates_pass_through(): + """An all-sky query submits "0.0,0.0" without hitting a name resolver.""" + payload = fermi.core.FermiLAT.query_object_async( + '0.0,0.0', searchradius=180, + obsdates='2008-08-04 15:43:36,2008-08-05 09:14:33', + get_query_payload=True) + assert payload['coordfield'] == '0.0,0.0' + assert payload['radius'] == 180 -def test_FermiLAT_query_async(patch_post): + +def test_payload_galactic_coordsystem(): + payload = fermi.core.FermiLAT.query_object_async( + FK5_COORDINATES, coordsystem='Galactic', get_query_payload=True) + assert payload['coordsystem'] == 'Galactic' + lon, lat = (float(x) for x in payload['coordfield'].split(',')) + assert lon == pytest.approx(121.174, abs=0.01) + assert lat == pytest.approx(-21.573, abs=0.01) + + +def test_payload_bad_coordsystem(): + with pytest.raises(ValueError, match='Unsupported coordsystem'): + fermi.core.FermiLAT.query_object_async( + FK5_COORDINATES, coordsystem='Ecliptic', get_query_payload=True) + + +def test_query_object_async_returns_query_id(patch_request): result = fermi.core.FermiLAT.query_object_async( - FK5_COORDINATES, energyrange_MeV='1000, 100000', - obsdates='2013-01-01 00:00:00, 2013-01-02 00:00:00') - assert result == DATA_FILES['result_url'] + FK5_COORDINATES, energyrange_MeV='1000,100000', + obsdates='2013-01-01 00:00:00,2013-01-02 00:00:00') + assert result == QUERY_ID + + method, url, kwargs = patch_request.calls[0] + assert method == 'POST' + assert url.endswith('/query') + # the payload must go out as JSON, not as a form body + assert kwargs['json']['coordfield'] == '10.68472,41.26875' + assert kwargs.get('data') is None + + +def test_get_status(patch_request): + status = fermi.core.FermiLAT.get_status(QUERY_ID) + assert status['state'] == 'Query completed' + + +def test_list_results(patch_request): + files = fermi.core.FermiLAT.list_results(QUERY_ID) + assert [f['name'] for f in files] == [ + 'L2601082002167F48EE3069_PH00.fits', + 'L2601082002167F48EE3069_SC00.fits'] -def test_getfermilatdatafile(patch_post): - result = fermi.core.get_fermilat_datafile(data_path(DATA_FILES['result']), - verbose=True) - assert result +def test_get_file_urls(patch_request): + assert fermi.core.FermiLAT.get_file_urls(QUERY_ID) == EXPECTED_URLS -def test_FermiLAT_query(patch_post): - # Make a query that results in small SC and PH file sizes +def test_query_object(patch_request): result = fermi.core.FermiLAT.query_object( - FK5_COORDINATES, energyrange_MeV='1000, 100000', - obsdates='2013-01-01 00:00:00, 2013-01-02 00:00:00') - assert result == DATA_FILES['fits'] + FK5_COORDINATES, energyrange_MeV='1000,100000', + obsdates='2013-01-01 00:00:00,2013-01-02 00:00:00') + assert result == EXPECTED_URLS + + +def test_failed_query_raises(request): + mp = request.getfixturevalue("monkeypatch") + mp.setattr(fermi.FermiLAT, '_request', _Router(status_key='status_failed')) + mp.setattr(fermi.FermiLAT, 'check_frequency', 0) + + with pytest.raises(RemoteServiceError, match='failed with state'): + fermi.core.FermiLAT.get_file_urls(QUERY_ID) + + +def test_missing_query_id_raises(request): + mp = request.getfixturevalue("monkeypatch") + mp.setattr(fermi.FermiLAT, '_request', + lambda *a, **kw: MockResponse(json.dumps({'error': 'bad'}).encode())) + + with pytest.raises(RemoteServiceError, match="did not contain a 'query_id'"): + fermi.core.FermiLAT.query_object_async(FK5_COORDINATES) + + +def test_file_url_prefers_absolute_url_from_server(): + """Forward-compatible: honour an absolute URL if the API starts sending one.""" + entry = {'name': 'x_PH00.fits', + 'url': 'https://example.org/somewhere/x_PH00.fits'} + assert fermi.core._file_url(entry) == 'https://example.org/somewhere/x_PH00.fits' diff --git a/astroquery/fermi/tests/test_fermi_remote.py b/astroquery/fermi/tests/test_fermi_remote.py index 23d2b92ef3..254cdf2e0d 100644 --- a/astroquery/fermi/tests/test_fermi_remote.py +++ b/astroquery/fermi/tests/test_fermi_remote.py @@ -1,8 +1,7 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst - -import pytest import astropy.coordinates as coord +import pytest from ... import fermi @@ -11,19 +10,43 @@ @pytest.mark.remote_data def test_FermiLAT_query_async(): - result = fermi.core.FermiLAT.query_object_async( - FK5_COORDINATES, energyrange_MeV='1000, 100000', - obsdates='2013-01-01 00:00:00, 2013-01-02 00:00:00') - assert 'https://fermi.gsfc.nasa.gov/cgi-bin/ssc/LAT/QueryResults.cgi?' in result + query_id = fermi.core.FermiLAT.query_object_async( + FK5_COORDINATES, energyrange_MeV='1000,100000', + obsdates='2013-01-01 00:00:00,2013-01-02 00:00:00') + assert isinstance(query_id, str) + assert query_id.startswith('L') + + status = fermi.core.FermiLAT.get_status(query_id) + assert 'state' in status @pytest.mark.remote_data def test_FermiLAT_query(): # Make a query that results in small SC and PH file sizes result = fermi.core.FermiLAT.query_object( - FK5_COORDINATES, energyrange_MeV='1000, 100000', - obsdates='2013-01-01 00:00:00, 2013-01-02 00:00:00') - # this test might be fragile? I'm not sure how stable the file names are + FK5_COORDINATES, energyrange_MeV='1000,100000', + obsdates='2013-01-01 00:00:00,2013-01-02 00:00:00') + + assert len(result) >= 1 for rr in result: - assert rr.startswith('https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/') + assert rr.startswith('https://') assert rr.endswith('_SC00.fits') or rr.endswith('_PH00.fits') + + +@pytest.mark.remote_data +def test_FermiLAT_query_zenithangle(): + result = fermi.core.FermiLAT.query_object( + FK5_COORDINATES, energyrange_MeV='1000,100000', + obsdates='2013-01-01 00:00:00,2013-01-02 00:00:00', + zenithangle=85) + assert len(result) >= 1 + + +@pytest.mark.remote_data +def test_FermiLAT_allsky_query(): + # all-sky: radius > 60 deg, observation window <= 24 hours + result = fermi.core.FermiLAT.query_object( + '0.0,0.0', searchradius=180, + obsdates='2008-08-04 15:43:36,2008-08-05 09:14:33', + energyrange_MeV='100,300000') + assert len(result) >= 1 diff --git a/docs/fermi/fermi.rst b/docs/fermi/fermi.rst index fbcd2b38f8..71a7434c76 100644 --- a/docs/fermi/fermi.rst +++ b/docs/fermi/fermi.rst @@ -12,11 +12,45 @@ centered on M 31 for the energy range 1 to 100 GeV for the first day in 2013. >>> from astroquery.fermi import FermiLAT >>> result = FermiLAT.query_object('M31', energyrange_MeV='1000, 100000', - ... obsdates='2013-01-01 00:00:00, 2013-01-02 00:00:00') + ... obsdates='2013-01-01 00:00:00, 2013-01-02 00:00:00') >>> print(result) # doctest: +IGNORE_OUTPUT ['https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L210111120827756AAA3A88_PH00.fits', 'https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L210111120827756AAA3A88_SC00.fits'] +`~astroquery.fermi.FermiLATClass.query_object` blocks until the server has +finished staging the data. To submit a query and come back to it later, use +the asynchronous interface, which returns the server-assigned ``query_id``: + +.. doctest-remote-data:: + + >>> from astroquery.fermi import FermiLAT + >>> query_id = FermiLAT.query_object_async('M31', energyrange_MeV='1000, 100000', + ... obsdates='2013-01-01 00:00:00, 2013-01-02 00:00:00') + >>> FermiLAT.get_status(query_id)['state'] # doctest: +IGNORE_OUTPUT + 'Query completed' + >>> FermiLAT.get_file_urls(query_id) # doctest: +IGNORE_OUTPUT + ['https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L210111120827756AAA3A88_PH00.fits', + 'https://fermi.gsfc.nasa.gov/FTP/fermi/data/lat/queries/L210111120827756AAA3A88_SC00.fits'] + +A maximum zenith angle can be supplied with ``zenithangle`` (the server +default is 180 degrees), and coordinates may be given in ``J2000`` (default), +``B1950`` or ``Galactic`` frames via ``coordsystem``. + +All-sky queries +=============== + +All-sky queries are handled specially by the server: the search radius must be +greater than 60 degrees (in practice, 180), the observation window must be no +longer than 24 hours, and the coordinates are ignored. + +.. doctest-remote-data:: + + >>> from astroquery.fermi import FermiLAT + >>> result = FermiLAT.query_object('0.0,0.0', searchradius=180, + ... energyrange_MeV='100, 300000', + ... obsdates='2008-08-04 15:43:36, 2008-08-05 09:14:33') + +Queries that exceed the 24-hour window are rejected by the server. Troubleshooting =============== @@ -28,7 +62,7 @@ If you are repeatedly getting failed queries, or bad/out-of-date results, try cl >>> from astroquery.fermi import FermiLAT >>> FermiLAT.clear_cache() -If this function is unavailable, upgrade your version of astroquery. +If this function is unavailable, upgrade your version of astroquery. The ``clear_cache`` function was introduced in version 0.4.7.dev8479. From db18460e2c86134b875c60514c900c2137da9d10 Mon Sep 17 00:00:00 2001 From: Sheimy Paz Serpa Date: Wed, 22 Jul 2026 15:45:15 -0400 Subject: [PATCH 2/8] fermi: use full API URL in changelog --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1514755ace..cf0249e5ab 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -76,7 +76,7 @@ fermi ^^^^^ - The module now uses the new Fermi LAT Data Query REST API - (``/ssc/data/access/lat/query/api/v1``), which replaces the retired + (``https://fermi.gsfc.nasa.gov/ssc/data/access/lat/query/api/v1``), which replaces the retired ``LATDataQuery.cgi`` endpoint whose HTML responses were previously scraped with regular expressions. The user-facing ``FermiLAT.query_object()`` signature is unchanged. [#XXXX] From 0a6f696f50a14ef1bdb2059bf0a0b627cc235672 Mon Sep 17 00:00:00 2001 From: Sheimy Paz Serpa Date: Tue, 28 Jul 2026 11:04:02 -0400 Subject: [PATCH 3/8] fermi: surface server error messages on failed queries --- astroquery/fermi/core.py | 39 +++++++++++++++++++++++++--- astroquery/fermi/tests/test_fermi.py | 13 ++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/astroquery/fermi/core.py b/astroquery/fermi/core.py index 40612b2f8d..d0b318789e 100644 --- a/astroquery/fermi/core.py +++ b/astroquery/fermi/core.py @@ -85,7 +85,7 @@ def query_object_async(self, *args, **kwargs): response = self._request( "POST", url=f"{self.base_url}/query", json=payload, timeout=self.TIMEOUT, cache=False) - response.raise_for_status() + _raise_for_status(response, context="Fermi LAT query submission") result = response.json() @@ -177,7 +177,7 @@ def get_status(self, query_id): response = self._request( "GET", url=f"{self.base_url}/query/{query_id}/status", timeout=self.TIMEOUT, cache=False) - response.raise_for_status() + _raise_for_status(response, context=f"Fermi LAT status ({query_id})") return response.json() def list_results(self, query_id): @@ -192,7 +192,7 @@ def list_results(self, query_id): response = self._request( "GET", url=f"{self.base_url}/query/{query_id}/results", timeout=self.RETRIEVAL_TIMEOUT, cache=False) - response.raise_for_status() + _raise_for_status(response, context=f"Fermi LAT results ({query_id})") return response.json().get('files', []) def wait_for_completion(self, query_id, *, check_frequency=None, @@ -266,6 +266,39 @@ def _parse_result(self, result, *, verbose=False, **kwargs): FermiLAT = FermiLATClass() +def _raise_for_status(response, *, context): + """ + Raise a `RemoteServiceError` carrying the server's error message. + + The Fermi API signals bad requests with an HTTP error status and a JSON + body of the form ``{"error": "..."}``. ``requests.raise_for_status`` only + reports the status code, so this helper pulls the message out of the body + (trying the ``error``, ``detail`` and ``message`` keys, then falling back + to the raw text) and surfaces it, as recommended by the astroquery API + specification. + """ + status_code = getattr(response, 'status_code', None) + if status_code is None or status_code < 400: + return + + message = None + try: + body = response.json() + except ValueError: + body = None + + if isinstance(body, dict): + for key in ('error', 'detail', 'message'): + if body.get(key): + message = body[key] + break + if message is None: + text = getattr(response, 'text', '') or '' + message = text.strip() or f"HTTP {status_code}" + + raise RemoteServiceError(f"{context} failed ({status_code}): {message}") + + def _file_url(entry): """ Build an absolute URL for one entry of the ``results`` file list. diff --git a/astroquery/fermi/tests/test_fermi.py b/astroquery/fermi/tests/test_fermi.py index cc6926f988..1cd4d2927a 100644 --- a/astroquery/fermi/tests/test_fermi.py +++ b/astroquery/fermi/tests/test_fermi.py @@ -184,3 +184,16 @@ def test_file_url_prefers_absolute_url_from_server(): entry = {'name': 'x_PH00.fits', 'url': 'https://example.org/somewhere/x_PH00.fits'} assert fermi.core._file_url(entry) == 'https://example.org/somewhere/x_PH00.fits' + + +def test_bad_input_surfaces_server_error_message(request): + """A 400 with {"error": ...} is reported, not swallowed into a bare status.""" + mp = request.getfixturevalue("monkeypatch") + body = json.dumps({'error': 'Invalid query parameters'}).encode() + mp.setattr(fermi.FermiLAT, '_request', + lambda *a, **kw: MockResponse(body, status_code=400)) + + with pytest.raises(RemoteServiceError, + match='Invalid query parameters'): + fermi.core.FermiLAT.query_object_async( + FK5_COORDINATES, energyrange_MeV='999999,1') From 46104aae52f73d17a536336b82db7ea9d2ef10f4 Mon Sep 17 00:00:00 2001 From: Sheimy Paz Serpa Date: Tue, 28 Jul 2026 14:07:47 -0400 Subject: [PATCH 4/8] fermi: add changelog entry for error-message handling --- CHANGES.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index cf0249e5ab..8bf365bf32 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -92,6 +92,10 @@ fermi now delegate to ``FermiLAT.get_file_urls()`` and take a ``query_id`` rather than a results-page URL. [#XXXX] - The module no longer emits an "Experimental" warning on import. [#XXXX] +- Failed queries now surface the server's error message: an HTTP error + response with a JSON body such as ``{"error": "..."}`` is raised as a + ``RemoteServiceError`` carrying that message, rather than a bare status + code. [#XXXX] mast From 3b82c1c6ff19268917cb08c2e8c1953dc12aa714 Mon Sep 17 00:00:00 2001 From: Sheimy Paz Serpa Date: Sun, 9 Aug 2026 21:20:32 -0400 Subject: [PATCH 5/8] fermi: send search radius as 'shapefield', the name the API accepts --- astroquery/fermi/core.py | 3 ++- astroquery/fermi/tests/test_fermi.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/astroquery/fermi/core.py b/astroquery/fermi/core.py index d0b318789e..52b9a43dc9 100644 --- a/astroquery/fermi/core.py +++ b/astroquery/fermi/core.py @@ -147,7 +147,8 @@ def _parse_args(self, name_or_coords, *, searchradius='', obsdates='', 'coordfield': _parse_coordinates(name_or_coords, coordsystem=coordsystem), 'coordsystem': coordsystem, - 'radius': searchradius, + # the API kept the legacy CGI-form name for the search radius + 'shapefield': searchradius, 'timefield': obsdates, 'timetype': timesys, 'energyfield': energyrange_MeV, diff --git a/astroquery/fermi/tests/test_fermi.py b/astroquery/fermi/tests/test_fermi.py index 1cd4d2927a..d7728ee8b6 100644 --- a/astroquery/fermi/tests/test_fermi.py +++ b/astroquery/fermi/tests/test_fermi.py @@ -75,7 +75,7 @@ def test_payload_field_names(): assert payload == {'coordfield': '10.68472,41.26875', 'coordsystem': 'J2000', - 'radius': 15, + 'shapefield': 15, 'timefield': '772109936,787661936', 'timetype': 'MET', 'energyfield': '100,300000', @@ -86,7 +86,7 @@ def test_payload_field_names(): def test_payload_defaults_radius_to_one_degree(): payload = fermi.core.FermiLAT.query_object_async( FK5_COORDINATES, get_query_payload=True) - assert payload['radius'] == 1 + assert payload['shapefield'] == 1 def test_payload_zenithangle_only_when_given(): @@ -106,7 +106,7 @@ def test_payload_allsky_coordinates_pass_through(): obsdates='2008-08-04 15:43:36,2008-08-05 09:14:33', get_query_payload=True) assert payload['coordfield'] == '0.0,0.0' - assert payload['radius'] == 180 + assert payload['shapefield'] == 180 def test_payload_galactic_coordsystem(): From 1be1bd50399a97f9bcb86cda412fd4f0197c1f4d Mon Sep 17 00:00:00 2001 From: Sheimy Paz Serpa Date: Mon, 17 Aug 2026 09:19:47 -0400 Subject: [PATCH 6/8] fermi: clarify that LATDataQuery.cgi is still running in parallel --- CHANGES.rst | 2 +- astroquery/fermi/core.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8bf365bf32..b58dbd24fa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -76,7 +76,7 @@ fermi ^^^^^ - The module now uses the new Fermi LAT Data Query REST API - (``https://fermi.gsfc.nasa.gov/ssc/data/access/lat/query/api/v1``), which replaces the retired + (``https://fermi.gsfc.nasa.gov/ssc/data/access/lat/query/api/v1``), which is replacing the ``LATDataQuery.cgi`` endpoint whose HTML responses were previously scraped with regular expressions. The user-facing ``FermiLAT.query_object()`` signature is unchanged. [#XXXX] diff --git a/astroquery/fermi/core.py b/astroquery/fermi/core.py index 52b9a43dc9..96f94911ef 100644 --- a/astroquery/fermi/core.py +++ b/astroquery/fermi/core.py @@ -7,7 +7,7 @@ GET /query/{id}/status -> {"state": ...} GET /query/{id}/results -> {"files": [{"name": ...}, ...]} -which replaced the legacy ``LATDataQuery.cgi`` form endpoint whose HTML +which is replacing the legacy ``LATDataQuery.cgi`` form endpoint whose HTML response pages this module used to scrape with regular expressions. """ From 949a52051ba14837cd6bccd5dfa122c4ffba3c63 Mon Sep 17 00:00:00 2001 From: Sheimy Paz Serpa Date: Tue, 18 Aug 2026 09:15:39 -0400 Subject: [PATCH 7/8] fermi: add PR number to changelog entries --- CHANGES.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b58dbd24fa..1c150e927f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -79,23 +79,23 @@ fermi (``https://fermi.gsfc.nasa.gov/ssc/data/access/lat/query/api/v1``), which is replacing the ``LATDataQuery.cgi`` endpoint whose HTML responses were previously scraped with regular expressions. The user-facing ``FermiLAT.query_object()`` - signature is unchanged. [#XXXX] + signature is unchanged. [#3647] - ``FermiLAT.query_object_async()`` now returns the server-assigned - ``query_id`` instead of the URL of an HTML results page. [#XXXX] + ``query_id`` instead of the URL of an HTML results page. [#3647] - New methods ``FermiLAT.get_status()``, ``FermiLAT.list_results()``, ``FermiLAT.wait_for_completion()`` and ``FermiLAT.get_file_urls()`` expose - the individual steps of the asynchronous query workflow. [#XXXX] + the individual steps of the asynchronous query workflow. [#3647] - New keyword arguments: ``zenithangle`` (maximum zenith angle in degrees) and ``coordsystem`` (``'J2000'``, ``'B1950'`` or ``'Galactic'``). All-sky - queries (radius > 60 deg, observation window <= 24 h) are now supported. [#XXXX] + queries (radius > 60 deg, observation window <= 24 h) are now supported. [#3647] - ``GetFermilatDatafile`` and ``get_fermilat_datafile`` are deprecated; they now delegate to ``FermiLAT.get_file_urls()`` and take a ``query_id`` rather - than a results-page URL. [#XXXX] -- The module no longer emits an "Experimental" warning on import. [#XXXX] + than a results-page URL. [#3647] +- The module no longer emits an "Experimental" warning on import. [#3647] - Failed queries now surface the server's error message: an HTTP error response with a JSON body such as ``{"error": "..."}`` is raised as a ``RemoteServiceError`` carrying that message, rather than a bare status - code. [#XXXX] + code. [#3647] mast From 6a740ef6773c204a8c76b7a13dc6b87f14c277b1 Mon Sep 17 00:00:00 2001 From: Sheimy Paz Serpa Date: Fri, 4 Sep 2026 11:41:08 -0400 Subject: [PATCH 8/8] fermi: cover error paths, polling loop, and deprecated shims in tests --- astroquery/fermi/tests/test_fermi.py | 69 ++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/astroquery/fermi/tests/test_fermi.py b/astroquery/fermi/tests/test_fermi.py index d7728ee8b6..b9de0403de 100644 --- a/astroquery/fermi/tests/test_fermi.py +++ b/astroquery/fermi/tests/test_fermi.py @@ -5,8 +5,10 @@ import astropy.coordinates as coord import pytest +from astropy.utils.exceptions import AstropyDeprecationWarning from astroquery.exceptions import RemoteServiceError +from astroquery.exceptions import TimeoutError as AstroqueryTimeoutError from astroquery.utils.mocks import MockResponse from ... import fermi @@ -197,3 +199,70 @@ def test_bad_input_surfaces_server_error_message(request): match='Invalid query parameters'): fermi.core.FermiLAT.query_object_async( FK5_COORDINATES, energyrange_MeV='999999,1') + + +def test_wait_for_completion_polls_until_done(request, capsys): + """The poll loop sleeps between checks and reports the elapsed time.""" + mp = request.getfixturevalue("monkeypatch") + responses = [MockResponse(read_data('status_running')), + MockResponse(read_data('status_complete'))] + mp.setattr(fermi.FermiLAT, '_request', lambda *a, **kw: responses.pop(0)) + mp.setattr(fermi.FermiLAT, 'check_frequency', 0) + + status = fermi.core.FermiLAT.wait_for_completion(QUERY_ID, verbose=True) + assert status['state'] == 'Query completed' + assert 'Query completed in' in capsys.readouterr().out + + +def test_wait_for_completion_times_out(request): + mp = request.getfixturevalue("monkeypatch") + mp.setattr(fermi.FermiLAT, '_request', + lambda *a, **kw: MockResponse(read_data('status_running'))) + + with pytest.raises(AstroqueryTimeoutError, match='did not complete within'): + fermi.core.FermiLAT.wait_for_completion(QUERY_ID, max_wait=0) + + +def test_non_json_error_body_falls_back_to_text(request): + """A non-JSON error body is reported verbatim rather than crashing.""" + mp = request.getfixturevalue("monkeypatch") + mp.setattr(fermi.FermiLAT, '_request', + lambda *a, **kw: MockResponse(b'Service unavailable', + status_code=503)) + + with pytest.raises(RemoteServiceError, match='Service unavailable'): + fermi.core.FermiLAT.get_status(QUERY_ID) + + +def test_file_url_from_plain_string(): + url = fermi.core._file_url('x_PH00.fits') + assert url.endswith('/x_PH00.fits') + assert url.startswith('http') + + +def test_file_url_from_relative_path(): + url = fermi.core._file_url({'path': 'y_SC00.fits'}) + assert url.endswith('/y_SC00.fits') + assert url.startswith('http') + + +def test_file_url_without_name_raises(): + with pytest.raises(RemoteServiceError, match='Could not determine'): + fermi.core._file_url({}) + + +def test_bad_coordinates_raise_value_error(): + with pytest.raises(ValueError, match='Coordinates not specified correctly'): + fermi.core._parse_coordinates(3.14159) + + +def test_deprecated_function_still_works(patch_request): + with pytest.warns(AstropyDeprecationWarning): + result = fermi.core.get_fermilat_datafile(QUERY_ID) + assert result == EXPECTED_URLS + + +def test_deprecated_class_still_works(patch_request): + with pytest.warns(AstropyDeprecationWarning): + getter = fermi.core.GetFermilatDatafile() + assert getter(QUERY_ID) == EXPECTED_URLS