diff --git a/CHANGES.rst b/CHANGES.rst index 2b250f4e59..2ac9acf26a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -24,6 +24,12 @@ esa.emds.einsteinprobe - New module to access the ESA Einstein Probe Science Archive. [#3511] +mast +^^^^ + +- Updated ``Catalogs`` interface for MAST TAP catalogs, including collection/catalog discovery helpers and a unified query workflow across + positional and non-positional searches. [#3582] + API changes @@ -76,6 +82,19 @@ mast - The ``objectname`` keyword is deprecated in ``MastMissions`` in favor of ``object_names``. [#3540] - The ``objectname`` parameter in ``Catalogs``, ``Observations``, ``Tesscut``, and ``utils`` is deprecated in favor of ``object_name``. [#3567] +- ``Catalogs`` has been refactored around VO-TAP queries. The new workflow uses ``collection`` + ``catalog`` (instead of HSC/PanSTARRS-specific + assumptions), supports discovery helpers (``get_collections``, ``get_catalogs``, ``get_column_metadata``), and + adds ``supports_spatial_queries`` to inspect positional-query support before querying. [#3582] +- ``Catalogs.query_criteria`` now provides a unified query interface for positional and non-positional searches, with support for + cone searches around coordinates or an object, STC-S regions via the ``region`` parameter, column selection, sorting, count-only queries, + pagination via ``limit``/``offset``, and a ``filters`` dictionary for column names that conflict with method arguments. [#3582] +- ``Catalogs.query_region`` and ``Catalogs.query_object`` now follow the same unified backend as ``query_criteria``, including support + for ``collection``/``catalog``, ``limit``/``offset``, ``select_cols``, sorting, and advanced criteria filters. [#3582] +- The legacy ``version``, ``pagesize``, and ``page`` parameters in ``Catalogs`` query methods are now deprecated in favor + of ``collection``/``catalog`` and ``limit``/``offset``. [#3582] +- Passing a collection name via ``Catalogs(..., catalog=...)`` is deprecated; use the ``collection`` parameter instead. [#3582] +- ``Catalogs`` legacy HSC-only helper methods (``query_hsc_matchid[_async]``, ``get_hsc_spectra[_async]``, and + ``download_hsc_spectra``) are deprecated and will be removed in a future release. [#3582] vo_conesearch ^^^^^^^^^^^^^ diff --git a/astroquery/mast/__init__.py b/astroquery/mast/__init__.py index fa0b9e8ff8..85c465724b 100644 --- a/astroquery/mast/__init__.py +++ b/astroquery/mast/__init__.py @@ -20,9 +20,6 @@ class Conf(_config.ConfigNamespace): ssoserver = _config.ConfigItem( 'https://ssoportal.stsci.edu', 'MAST SSO Portal server.') - catalogs_server = _config.ConfigItem( - 'https://catalogs.mast.stsci.edu', - 'Catalogs.MAST server.') timeout = _config.ConfigItem( 600, 'Time limit for requests from the STScI server.') @@ -37,18 +34,28 @@ class Conf(_config.ConfigNamespace): conf = Conf() -from .cutouts import TesscutClass, Tesscut, ZcutClass, Zcut, HapcutClass, Hapcut -from .observations import Observations, ObservationsClass, MastClass, Mast +from . import utils from .collections import Catalogs, CatalogsClass +from .cutouts import Hapcut, HapcutClass, Tesscut, TesscutClass, Zcut, ZcutClass from .missions import MastMissions, MastMissionsClass -from . import utils - -__all__ = ['Observations', 'ObservationsClass', - 'Catalogs', 'CatalogsClass', - 'MastMissions', 'MastMissionsClass', - 'Mast', 'MastClass', - 'Tesscut', 'TesscutClass', - 'Zcut', 'ZcutClass', - 'Hapcut', 'HapcutClass', - 'Conf', 'conf', 'utils', - ] +from .observations import Mast, MastClass, Observations, ObservationsClass + +__all__ = [ + "Observations", + "ObservationsClass", + "Catalogs", + "CatalogsClass", + "MastMissions", + "MastMissionsClass", + "Mast", + "MastClass", + "Tesscut", + "TesscutClass", + "Zcut", + "ZcutClass", + "Hapcut", + "HapcutClass", + "Conf", + "conf", + "utils", +] diff --git a/astroquery/mast/catalog_collection.py b/astroquery/mast/catalog_collection.py new file mode 100644 index 0000000000..6dd82ae530 --- /dev/null +++ b/astroquery/mast/catalog_collection.py @@ -0,0 +1,530 @@ +import difflib +from dataclasses import dataclass +from typing import Dict, Optional + +from astropy.table import Table +from pyvo.dal import DALQueryError, DALServiceError, TAPService + +from .. import log +from ..exceptions import InvalidQueryError +from . import conf, utils + +__all__ = ["CatalogCollection"] + +DEFAULT_CATALOGS = { + "caom": "dbo.obspointing", + "gaiadr3": "dbo.gaia_source", + "hsc": "dbo.SumMagAper2CatView", + "hscv2": "dbo.SumMagAper2CatView", + "missionmast": "dbo.hst_science_missionmast", + "ps1dr1": "dbo.MeanObjectView", + "ps1dr2": "dbo.MeanObjectView", + "ps1_dr2": "ps1_dr2.forced_mean_object", + "skymapperdr4": "dr4.master", + "tic": "dbo.CatalogRecord", + "classy": "dbo.targets", + "ullyses": "dbo.sciencemetadata", + "goods": "dbo.goods_master_view", + "3dhst": "dbo.HLSP_3DHST_summary", + "candels": "dbo.candels_master_view", + "deepspace": "dbo.DeepSpace_Summary", + "tic_v82": "tic_v82.source", +} + +GROUPED_COLLECTION_ENDPOINTS = ["mast_catalogs", "roman_catalogs"] + + +@dataclass +class CatalogMetadata: + """ + Data class to hold metadata about a catalog, including column metadata, + RA/Dec column names, and spatial query support. + """ + + column_metadata: Table + ra_column: Optional[str] + dec_column: Optional[str] + supports_spatial_queries: bool + + +class CatalogCollection: + """ + This class provides an interface to interact with MAST catalog collections via TAP service. + """ + + TAP_BASE_URL = conf.server + "/vo-tap/api/v0.1/" + _discovered_collections = None + _collection_parent_map = None + + @classmethod + def discover_collections(cls): + """ + Discover collection names available through TAP and track parent collections. + + Returns + ------- + `~astropy.table.Table` + A table containing collection_name and parent_collection columns. + """ + if cls._discovered_collections is not None: + return cls._discovered_collections + + log.debug("Fetching available collections from MAST TAP service.") + + # Query TAP service for collection names + url = cls.TAP_BASE_URL + "openapi.json" + response = utils._simple_request(url) + response.raise_for_status() + data = response.json() + + try: + collection_enum = data["components"]["schemas"]["CatalogName"]["enum"] + except KeyError: + raise RuntimeError("Failed to discover collections from TAP service: Unexpected response format") + + collection_parent_map = {} + + # Discover collections stored under grouped TAP collections + for parent_collection in GROUPED_COLLECTION_ENDPOINTS: + if parent_collection not in collection_enum: + continue + + tap_service = TAPService(cls.TAP_BASE_URL + parent_collection) + result = tap_service.run_sync("SELECT TOP 5000 table_name FROM tap_schema.tables") + tables = result.to_table() + + for table_name in tables["table_name"]: + table_name = str(table_name) + if table_name.lower().startswith("tap_schema."): + continue + + collection_name = table_name.split(".", 1)[0].lower() + collection_parent_map.setdefault(collection_name, parent_collection) + + # Include standalone collections in map + for collection_name in collection_enum: + normalized_name = collection_name.lower() + if normalized_name in GROUPED_COLLECTION_ENDPOINTS: + continue + collection_parent_map.setdefault(normalized_name, normalized_name) + + collection_names = sorted(collection_parent_map.keys()) + parent_names = [collection_parent_map[name] for name in collection_names] + cls._collection_parent_map = collection_parent_map + cls._discovered_collections = Table( + [collection_names, parent_names], + names=("collection_name", "parent_collection"), + ) + + return cls._discovered_collections + + @classmethod + def get_parent_collection(cls, collection_name): + """ + Return the parent TAP collection for a user-facing collection name. + + Parameters + ---------- + collection_name : str + The user-facing collection name to get the parent collection for. + """ + if not isinstance(collection_name, str): + raise InvalidQueryError(f"Collection name must be a string, got {type(collection_name)}") + + if cls._collection_parent_map is None: + cls.discover_collections() + + normalized_name = collection_name.lower().strip() + parent_collection = cls._collection_parent_map.get(normalized_name) + + if parent_collection is None: + raise InvalidQueryError(f"Collection '{collection_name}' not found") + + return parent_collection + + def __init__(self, collection): + """ + Initialize a CatalogCollection object for interacting with a MAST catalog collection. + + Parameters + ---------- + collection : str + The name of the MAST catalog collection to interact with. + """ + if not isinstance(collection, str): + raise ValueError(f"Collection name must be a string, got {type(collection)}") + + self.name = collection.strip().lower() + self._parent_collection = None + self._tap_service = None + + # Get catalogs within this collection + self._catalogs = None # Lazy-loaded property + + # ADQL functions supported by this collection's TAP service + self._supported_adql_functions = None # Lazy-loaded property + + # Determine the default catalog lazily to avoid requests during initialization + self._default_catalog = None + + # Cache for catalog metadata to avoid redundant queries + self._catalog_metadata_cache: Dict[str, CatalogMetadata] = dict() + + # Cache the catalog lookup mapping for validating catalog names in queries + self._catalog_lookup = None + self._no_prefix_lookup = None + + @property + def parent_collection(self): + if self._parent_collection is None: + self._parent_collection = self.get_parent_collection(self.name) + return self._parent_collection + + @property + def tap_service(self): + if self._tap_service is None: + self._tap_service = TAPService(self.TAP_BASE_URL + self.parent_collection) + return self._tap_service + + @property + def default_catalog(self): + if self._default_catalog is None: + self._default_catalog = self.get_default_catalog() + return self._default_catalog + + @property + def catalogs(self): + if self._catalogs is None: + self._catalogs = self._fetch_catalogs() + return self._catalogs + + @property + def catalog_names(self): + return self.catalogs["catalog_name"].tolist() + + @property + def supported_adql_functions(self): + if self._supported_adql_functions is None: + self._supported_adql_functions = self._fetch_adql_supported_functions() + return self._supported_adql_functions + + def get_catalog_metadata(self, catalog): + """ + For a given catalog, cache and return metadata about its columns and capabilities. + + Parameters + ---------- + catalog : str + The catalog within the collection to get metadata for. + + Returns + ------- + CatalogMetadata + A CatalogMetadata object containing metadata about the specified catalog, including column metadata, + RA/Dec column names, and spatial query support. + """ + # Verify catalog validity for this collection + catalog = self._verify_catalog(catalog) + + # Check cache first + if catalog in self._catalog_metadata_cache: + return self._catalog_metadata_cache[catalog] + + log.debug(f"Fetching catalog metadata for collection '{self.name}', catalog '{catalog}' from MAST TAP service.") + + # Get column metadata + metadata = self._get_column_metadata(catalog) + + # Get RA/Dec column names + ra_col, dec_col = self._get_ra_dec_column_names(metadata) + + # Determine if spatial queries are supported + supports_adql_geometry = all(func in self.supported_adql_functions for func in ("POINT", "CIRCLE", "CONTAINS")) + + # Try an inexpensive spatial query if RA/Dec columns are known + supports_spatial_queries = supports_adql_geometry and ra_col is not None and dec_col is not None + if supports_spatial_queries: + # If an ra and dec column exist, test spatial query support + spatial_query = ( + f"SELECT TOP 0 * FROM {catalog} WHERE CONTAINS(POINT('ICRS', {ra_col}, {dec_col}), " + "CIRCLE('ICRS', 0, 0, 0.001)) = 1" + ) + try: + self.tap_service.search(spatial_query) + except DALQueryError: + supports_spatial_queries = False + + meta = CatalogMetadata( + column_metadata=metadata, + ra_column=ra_col, + dec_column=dec_col, + supports_spatial_queries=supports_spatial_queries, + ) + + # Cache and return + self._catalog_metadata_cache[catalog] = meta + return meta + + def get_default_catalog(self): + """ + Get the default catalog for this collection. This is the first catalog that does not start with "tap_schema.". + + Returns + ------- + str + The default catalog name. + """ + # Check if collection has a known default catalog + if self.name in DEFAULT_CATALOGS: + return DEFAULT_CATALOGS[self.name] + + # Pick default catalog = first one that does NOT start with "tap_schema." + default_catalog = next((c for c in self.catalog_names if not c.startswith("tap_schema.")), None) + + # If no valid catalog found, fallback to the first one + if default_catalog is None: + default_catalog = self.catalog_names[0] if self.catalog_names else None + + return default_catalog + + def run_tap_query(self, adql, *, run_async=False): + """ + Run a TAP query against the specified catalog. + + Parameters + ---------- + adql : str + The ADQL query string. + run_async : bool, optional + If True, run the query in asynchronous mode. This mode is more robust and + preferable for long-running queries. Default is False (synchronous mode). + + Returns + ------- + response : `~astropy.table.Table` + The result of the TAP query as an Astropy Table. + """ + log.debug(f"Running TAP query on collection '{self.name}': {adql}") + try: + if run_async: + result = self.tap_service.run_async(adql) + else: + result = self.tap_service.run_sync(adql) + except DALQueryError as e: + raise InvalidQueryError(f"TAP query failed for collection '{self.name}': {e}") + except DALServiceError as e: + if e.code == 504: + raise RuntimeError( + f"TAP query timed out for collection '{self.name}'. This may be due to a long-running query or " + "server issues. Consider setting run_async=True for more robust handling of long queries." + ) + return result.to_table() + + def _fetch_catalogs(self): + """ + Retrieve the list of catalogs in this collection. + + Returns + ------- + `~astropy.table.Table` + A table containing the catalog names and descriptions for this collection. + """ + log.debug(f"Fetching available catalogs for collection '{self.name}' from MAST TAP service.") + query = "SELECT TOP 5000 table_name, description FROM tap_schema.tables" + + # If this catalog is within a grouped collection, filter to only tables that belong to this collection + if self.parent_collection in GROUPED_COLLECTION_ENDPOINTS: + query += f" WHERE table_name LIKE '{self.name}.%'" + + result = self.tap_service.run_sync(query) + + # Rename table_name to catalog_name for clarity + result_table = result.to_table() + result_table.rename_column("table_name", "catalog_name") + + return result_table + + def _fetch_adql_supported_functions(self): + """ + Retrieve the ADQL supported functions of the TAP service. + + Returns + ------- + set + A set of supported ADQL geometry functions (e.g. "POINT", "CIRCLE", "CONTAINS", etc.) for + this collection's TAP service. + """ + adql_functions = ["CIRCLE", "POLYGON", "POINT", "CONTAINS", "INTERSECTS"] + supported = set() + feature_id = "ivo://ivoa.net/std/TAPRegExt#features-adqlgeo" + for capability in self.tap_service.capabilities: + if capability.standardid != "ivo://ivoa.net/std/TAP": + continue + + for lang in capability.languages: + if lang.name != "ADQL": + continue + + for func in adql_functions: + if lang.get_feature(feature_id, func): + supported.add(func) + + return supported + + def _verify_catalog(self, catalog): + """ + Verify that the specified catalog is valid for this collection and return the correct catalog name. + Raises an error if the catalog is not valid. + + Parameters + ---------- + catalog : str + The catalog to be verified. + + Returns + ------- + str + The validated catalog name. + + Raises + ------ + InvalidQueryError + If the specified catalog is not valid for the given collection. + """ + catalog = catalog.lower().strip() + + if self._catalog_lookup is not None and self._no_prefix_lookup is not None: + lookup = self._catalog_lookup + no_prefix_map = self._no_prefix_lookup + else: + # Build a mapping for case-insensitive and no-prefix lookup + lookup = {} + no_prefix_map = {} + for cat in self.catalog_names: + cat_lower = cat.lower() + lookup[cat_lower] = cat # case-insensitive match + no_prefix = cat_lower.split(".")[-1] + if no_prefix not in no_prefix_map: + no_prefix_map[no_prefix] = [cat] # no-prefix match (first occurrence) + else: + no_prefix_map[no_prefix].append(cat) + + # Add unambiguous no-prefix matches to lookup + for no_prefix, cats in no_prefix_map.items(): + if len(cats) == 1: + lookup[no_prefix] = cats[0] + + # Cache the lookup maps for future calls + self._catalog_lookup = lookup + self._no_prefix_lookup = no_prefix_map + + # Direct or unambiguous no-prefix match + if catalog in lookup: + return lookup[catalog] + + # Check for ambiguous no-prefix matches + if catalog in no_prefix_map and len(no_prefix_map[catalog]) > 1: + matches = ", ".join(no_prefix_map[catalog]) + raise InvalidQueryError( + f"Catalog '{catalog}' is ambiguous for collection '{self.name}'. " + f"It matches multiple catalogs: {matches}. Please specify the full catalog name." + ) + + # Suggest closest match (based on full catalog names) + closest = difflib.get_close_matches(catalog, self.catalog_names, n=1) + suggestion = f" Did you mean '{closest[0]}'?" if closest else "" + + raise InvalidQueryError( + f"Catalog '{catalog}' is not recognized for collection '{self.name}'." + f"{suggestion} Available catalogs are: {', '.join(self.catalog_names)}" + ) + + def _get_column_metadata(self, catalog): + """ + For a given catalog, return metadata about its columns. + + Parameters + ---------- + catalog : str + The catalog within the collection to get metadata for. + + Returns + ------- + response : `~astropy.table.Table` + A table containing metadata about the specified table, including column names, data types, and descriptions. + """ + query = f""" + SELECT TOP 5000 + column_name, + datatype, + unit, + ucd, + description + FROM tap_schema.columns + WHERE table_name = '{catalog}' + """ + result = self.tap_service.run_sync(query) + + if len(result) == 0: + raise InvalidQueryError(f"Catalog '{catalog}' not found in collection '{self.name}'.") + + return result.to_table() + + def _get_ra_dec_column_names(self, column_metadata): + """ + Return the RA and Dec column names for a given catalog and table. + + Parameters + ---------- + column_metadata : `~astropy.table.Table` + The column metadata table for a catalog. + + Returns + ------- + tuple + A tuple containing the (ra_column, dec_column) names. + """ + # Look for a column with UCD 'pos.eq.ra;meta.main' and 'pos.eq.dec;meta.main' + ra_col = None + dec_col = None + for name, ucd in zip(column_metadata["column_name"], column_metadata["ucd"]): + if ucd and "pos.eq.ra;meta.main" in ucd.lower(): + # TODO: ps1_dr2.mean_object and ps1_dr2.stacked_object has a column that can be used, + # but is not labeled with "meta.main" + ra_col = name + elif ucd and "pos.eq.dec;meta.main" in ucd.lower(): + dec_col = name + return ra_col, dec_col + + def _verify_criteria(self, catalog, **criteria): + """ + Check that criteria keyword arguments are valid column names for the specified collection and catalog. + + Parameters + ---------- + catalog : str + The catalog within the collection to query. + **criteria + Keyword arguments representing criteria filters to apply. + + Raises + ------ + InvalidQueryError + If a keyword does not match any valid column names, an error is raised that suggests the closest + matching column name, if available. + """ + if not criteria: + return + col_names = list(self.get_catalog_metadata(catalog).column_metadata["column_name"]) + col_name_lookup = {col.lower(): col for col in col_names} + + # Check each criteria argument for validity + for kwd in criteria: + if kwd.lower() not in col_name_lookup: + # Suggest closest match for invalid keyword + closest = difflib.get_close_matches(kwd.lower(), list(col_name_lookup.keys()), n=1) + suggestion = f" Did you mean '{col_name_lookup[closest[0]]}'?" if closest else "" + raise InvalidQueryError( + f"Filter '{kwd}' is not recognized for collection '{self.name}' and " + f"catalog '{catalog}'.{suggestion}" + ) diff --git a/astroquery/mast/collections.py b/astroquery/mast/collections.py index ed6baf00f2..6ea08a25b3 100644 --- a/astroquery/mast/collections.py +++ b/astroquery/mast/collections.py @@ -3,481 +3,740 @@ MAST Collections ================ -This module contains various methods for querying MAST collections such as catalogs. +This module contains methods for discovering and querying MAST catalog collections. """ import difflib -from json import JSONDecodeError -import warnings import os +import re import time +import warnings +from collections.abc import Iterable -from requests import HTTPError, RequestException - -import astropy.units as u import astropy.coordinates as coord -from astropy.table import Table, Row -from astropy.utils.decorators import deprecated_renamed_argument +import astropy.units as u +import requests +from astropy.table import Row, Table +from astropy.time import Time +from astropy.utils.decorators import deprecated, deprecated_renamed_argument -from ..utils import commons, async_to_sync +from ..exceptions import InputWarning, InvalidQueryError, NoResultsWarning +from ..utils import async_to_sync from ..utils.class_or_instance import class_or_instance -from ..exceptions import InvalidQueryError, MaxResultsWarning, InputWarning - -from . import utils, conf +from . import conf, utils +from .catalog_collection import CatalogCollection from .core import MastQueryWithLogin +try: + from regions import CircleSkyRegion, PolygonSkyRegion + HAS_REGIONS = True +except ImportError: + HAS_REGIONS = False -__all__ = ['Catalogs', 'CatalogsClass'] +__all__ = ["Catalogs", "CatalogsClass"] @async_to_sync class CatalogsClass(MastQueryWithLogin): """ - MAST catalog query class. - - Class for querying MAST catalog data. + Class for discovering and querying MAST catalog collections. """ - def __init__(self): + TAP_BASE_URL = conf.server + "/vo-tap/api/v0.1/" + _collections_cache = dict() + + def __init__(self, collection=None, catalog=None): super().__init__() - services = {"panstarrs": {"path": "panstarrs/{data_release}/{table}.json", - "args": {"data_release": "dr2", "table": "mean"}}} - self._catalogs_mast_search_options = ['columns', 'sort_by', 'table', 'data_release'] + self._available_collections = None # Lazy load on first request + self._no_longer_supported_collections = ["ctl", "diskdetective", "galex", "plato"] + self._renamed_collections = {"panstarrs": "ps1_dr2", "gaia": "gaiadr3"} - self._service_api_connection.set_service_params(services, "catalogs", True) + # Default initialization of this class should not trigger network requests + # Only set collection and catalog if explicitly provided, otherwise defer to property setters + # which will handle defaults without network calls + if not collection: + self._collection = CatalogCollection("hsc") # default collection + else: + self.collection = collection # Use the setter for validation if collection is provided - self.catalog_limit = None - self._current_connection = None - self._service_columns = dict() # Info about columns for Catalogs.MAST services + if not catalog: + self._catalog = self._collection.default_catalog + else: + self.catalog = catalog # Use the setter for validation if catalog is provided - def _parse_result(self, response, *, verbose=False): + @property + def collection(self): + """ + The current MAST collection to be queried. + """ + # Return the collection name instead of the object for easier user interaction, + # but keep the object internally for API calls + return self._collection.name - results_table = self._current_connection._parse_result(response, verbose=verbose) + @collection.setter + def collection(self, collection): + """ + Setter that creates a CatalogCollection object when the collection is changed and updates + the catalog accordingly. + """ + collection_obj = self._get_collection_obj(collection) + self._collection = collection_obj - if len(results_table) == self.catalog_limit: - warnings.warn("Maximum catalog results returned, may not include all sources within radius.", - MaxResultsWarning) + # Only change catalog if not set yet or invalid for this collection + if not hasattr(self, "_catalog") or self._catalog not in collection_obj.catalog_names: + self._catalog = collection_obj.default_catalog - return results_table + @property + def catalog(self): + """ + The current catalog within the MAST collection. + """ + return self._catalog - def _get_service_col_config(self, catalog, release='dr2', table='mean'): + @catalog.setter + def catalog(self, catalog): + """ + Setter that verifies that the catalog is valid for the current collection. """ - For a given Catalogs.MAST catalog, return a list of all searchable columns and their descriptions. - As of now, this function is exclusive to the Pan-STARRS catalog. + catalog = self._collection._verify_catalog(catalog) + self._catalog = catalog - Parameters - ---------- - catalog : str - The catalog to be queried. - release : str, optional - Catalog data release to query from. - table : str, optional - Catalog table to query from. + @property + def available_collections(self): + """ + The list of available MAST catalog collections. + """ + if self._available_collections is None: + table = self.get_collections() + self._available_collections = table["collection_name"].tolist() + return self._available_collections + + @class_or_instance + def get_collections(self): + """ + Return a list of available collections from MAST. Returns ------- - response : `~astropy.table.Table` that contains columns names, types, and descriptions + response : `~astropy.table.Table` + A table containing the available MAST collections. """ - # Only supported for PanSTARRS currently - if catalog != 'panstarrs': - return + # If already cached, use it directly + if getattr(self, "_available_collections", None): + return Table([self._available_collections], names=("collection_name",)) - service_key = (catalog, release, table) - if service_key not in self._service_columns: - try: - # Send server request to get column list for given parameters - request_url = f'{conf.catalogs_server}/api/v0.1/{catalog}/{release}/{table}/metadata.json' - resp = utils._simple_request(request_url) + # Otherwise, fetch from TAP service discovery, including grouped collections. + collection_table = CatalogCollection.discover_collections()[["collection_name"]] + return collection_table - # Parse JSON and extract necessary info - results = resp.json() + @class_or_instance + def get_catalogs(self, collection=None): + """ + For a given collection, return a list of available catalogs. + + Parameters + ---------- + collection : str, optional + The collection to be queried. - # Prepare data for Table creation - rows = [] - for result in results: - colname = result.get('column_name') or result.get('name') - dtype = result.get('db_type') - desc = result.get('description', '') + Returns + ------- + response : `~astropy.table.Table` + A table containing the available catalogs within the specified collection. + """ + # If no collection specified, use the class attribute + collection_obj = self._get_collection_obj(collection) if collection else self._collection + catalogs = collection_obj.catalogs - if colname is None or dtype is None: - continue # Skip invalid entries + # Do not expose tap_schema catalogs to users + mask = [not str(name).startswith('tap_schema') for name in catalogs['catalog_name']] + catalogs = catalogs[mask] - rows.append((colname, dtype, desc)) + return catalogs - # Create Table with parsed data - col_table = Table(rows=rows, names=('name', 'data_type', 'description')) - self._service_columns[service_key] = col_table + @class_or_instance + def get_column_metadata(self, collection=None, catalog=None): + """ + For a given collection and catalog, return metadata about the catalog's columns. - except JSONDecodeError as ex: - raise JSONDecodeError(f'Failed to decode JSON response while attempting to get column list' - f' for {catalog} catalog {table}, {release}: {ex}') - except RequestException as ex: - raise ConnectionError(f'Failed to connect to the server while attempting to get column list' - f' for {catalog} catalog {table}, {release}: {ex}') - except Exception as ex: - raise RuntimeError(f'An unexpected error occurred while attempting to get column list' - f' for {catalog} catalog {table}, {release}: {ex}') + Parameters + ---------- + collection : str, optional + The collection to be queried. + catalog : str, optional + The catalog within the collection to get metadata for. - return self._service_columns[service_key] + Returns + ------- + response : `~astropy.table.Table` + A table containing metadata about the specified catalog, including column names, data types, + and descriptions. + """ + collection_obj, catalog = self._parse_inputs(collection, catalog) + return collection_obj.get_catalog_metadata(catalog).column_metadata - def _validate_service_criteria(self, catalog, **criteria): + def supports_spatial_queries(self, collection=None, catalog=None): """ - Check that criteria keyword arguments are valid column names for the service. - Raises InvalidQueryError if a criteria argument is invalid. + Check if a given collection and catalog support spatial queries. Parameters ---------- - catalog : str - The catalog to be queried. - **criteria - Keyword arguments representing criteria filters to apply. + collection : str, optional + The collection to be queried. + catalog : str, optional + The catalog within the collection to check. - Raises + Returns ------- - InvalidQueryError - If a keyword does not match any valid column names, an error is raised that suggests the closest - matching column name, if available. - """ - # Ensure that self._service_columns is populated - release = criteria.get('data_release', 'dr2') - table = criteria.get('table', 'mean') - col_config = self._get_service_col_config(catalog, release, table) - - if col_config: - # Check each criteria argument for validity - valid_cols = list(col_config['name']) + self._catalogs_mast_search_options - for kwd in criteria.keys(): - col = next((name for name in valid_cols if name.lower() == kwd.lower()), None) - if not col: - closest_match = difflib.get_close_matches(kwd, valid_cols, n=1) - error_msg = ( - f"Filter '{kwd}' does not exist for {catalog} catalog {table}, {release}. " - f"Did you mean '{closest_match[0]}'?" - if closest_match - else f"Filter '{kwd}' does not exist for {catalog} catalog {table}, {release}." - ) - raise InvalidQueryError(error_msg) + bool + True if the specified catalog supports spatial queries, False otherwise. + """ + collection_obj, catalog = self._parse_inputs(collection, catalog) + return collection_obj.get_catalog_metadata(catalog).supports_spatial_queries @class_or_instance - def query_region_async(self, coordinates, *, radius=0.2*u.deg, catalog="Hsc", - version=None, pagesize=None, page=None, **criteria): + @deprecated_renamed_argument( + "version", + None, + since="0.4.13", + message="The `version` argument is deprecated and " + "will be removed in a future release. Please use `collection` and `catalog` instead.", + ) + @deprecated_renamed_argument( + "pagesize", + None, + since="0.4.13", + message="The `pagesize` argument is deprecated " + "and will be removed in a future release. Please use `limit` instead.", + ) + @deprecated_renamed_argument( + "page", + None, + since="0.4.13", + message="The `page` argument is deprecated " + "and will be removed in a future release. Please use `offset` instead.", + ) + @deprecated_renamed_argument("objectname", "object_name", since="0.4.13") + def query_criteria( + self, + collection=None, + *, + catalog=None, + coordinates=None, + region=None, + object_name=None, + radius=0.2 * u.deg, + resolver=None, + limit=5000, + offset=0, + count_only=False, + select_cols=None, + sort_by=None, + sort_desc=False, + filters=None, + run_async=False, + return_adql=False, + version=None, + pagesize=None, + page=None, + **criteria, + ): """ - Given a sky position and radius, returns a list of catalog entries. - See column documentation for specific catalogs `here `__. + Query a MAST catalog from a given collection using criteria filters. To return columns for a given + collection and catalog, use `~astroquery.mast.CatalogsClass.get_column_metadata`. Parameters ---------- - coordinates : str or `~astropy.coordinates` object - The target around which to search. It may be specified as a - string or as the appropriate `~astropy.coordinates` object. - radius : str or `~astropy.units.Quantity` object, optional - Default 0.2 degrees. - The string must be parsable by `~astropy.coordinates.Angle`. The - appropriate `~astropy.units.Quantity` object from - `~astropy.units` may also be used. Defaults to 0.2 deg. + collection : str, optional + The collection to be queried. If None, uses the instance's `collection` attribute. catalog : str, optional - Default HSC. - The catalog to be queried. - version : int, optional - Version number for catalogs that have versions. Default is highest version. + The catalog within the collection to query. If None, uses the instance's `catalog` attribute. + coordinates : str or `~astropy.coordinates` object, optional + The target around which to search. It may be specified as a string (e.g., '350 -80') or as an + Astropy coordinates object. + region : str | iterable | `~regions.CircleSkyRegion` | `~regions.PolygonSkyRegion`, optional + The region to search within. It may be specified as a STC-S POLYGON or CIRCLE string + (e.g., 'CIRCLE 350 -80 0.2'), an iterable of coordinate pairs, or as an + `~regions.CircleSkyRegion` or `~regions.PolygonSkyRegion`. + object_name : str, optional + The name of the object to resolve and search around. + radius : str or `~astropy.units.Quantity` object, optional + The search radius around the target coordinates or object. Default 0.2 degrees. + resolver : str, optional + The name resolver service to use when resolving ``object_name``. + limit : int, optional + The maximum number of results to return. Default is 5000. + offset : int, optional + The number of rows to skip before starting to return rows. Default is 0. + count_only : bool, optional + If True, only return the count of matching records instead of the records themselves. Default is False. + select_cols : list of str, optional + List of column names to include in the result. If None or empty, all columns are returned. + sort_by : str or list of str, optional + Column name(s) to sort the results by. + sort_desc : bool or list of bool, optional + Indicates whether to sort in descending order for each column in ``sort_by``. If a single bool, + applies to all columns. If a list, must match length of ``sort_by``. Default is False (ascending order). + filters : dict, optional + Another parameter to specify criteria filters as a dictionary. Use this option when the name of a column + conflicts with a named parameter of this method. + run_async : bool, optional + If True, run the query in asynchronous mode. This mode is more robust and preferable + for long-running queries. If you encounter timeouts or connection issues with large queries, + set this to True. Default is False (synchronous mode). + return_adql : bool, optional + If True, return the ADQL query string instead of executing the query. Default is False. + When False, the ADQL query string is also returned in the metadata of the result table. + version : str, optional + Deprecated. The version argument is no longer used. Please use ``collection`` and ``catalog`` instead. pagesize : int, optional - Default None. - Can be used to override the default pagesize for (set in configs) this query only. - E.g. when using a slow internet connection. + Deprecated. The pagesize argument is no longer used. Please use ``limit`` instead. page : int, optional - Default None. - Can be used to override the default behavior of all results being returned to obtain a - specific page of results. + Deprecated. The page argument is no longer used. Please use ``offset`` instead. **criteria - Other catalog-specific keyword args. - These can be found in the (service documentation)[https://mast.stsci.edu/api/v0/_services.html] - for specific catalogs. For example, one can specify the magtype for an HSC search. - For catalogs available through Catalogs.MAST (PanSTARRS), the Column Name is the keyword, and the argument - should be either an acceptable value for that parameter, or a list consisting values, or tuples of - decorator, value pairs (decorator, value). In addition, columns may be used to select the return columns, - consisting of a list of column names. Results may also be sorted through the query with the parameter - sort_by composed of either a single Column Name to sort ASC, or a list of Column Nmaes to sort ASC or - tuples of Column Name and Direction (ASC, DESC) to indicate sort order (Column Name, DESC). - Detailed information of Catalogs.MAST criteria usage can - be found `here `__. + Keyword arguments representing criteria filters to apply. + + Criteria syntax: + + - Strings support wildcards using '*' (converted to SQL '%') and '%'. + - Lists are combined with OR for positive values; empty lists yield no matches. + - Numeric columns support comparison operators ('<', '<=', '>', '>=') and inclusive ranges using + the syntax 'low..high' (e.g., '5..10'). Mixed lists of numbers and comparisons are OR-combined. + - Negation: Prefix any value with '!' to negate that predicate. For list inputs, all negated values + for the same column are AND-combined, then ANDed with the OR of the positive values: + (neg1 AND neg2 AND ...) AND (pos1 OR pos2 OR ...). + - Temporal columns can be specified as strings in a recognized date/time format (e.g., 'YYYY', 'YYYY-MM-DD', + 'YYYY-MM-DD hh:mm:ss', etc.), `~astropy.time.Time` objects, or `datetime` objects. The same comparison + operators and range syntax as numeric columns can be used to filter temporal columns + based on date/time values. + + Examples: + + - file_suffix=['A', 'B', '!C'] -> (file_suffix != 'C') AND (file_suffix IN ('A', 'B')) + - size=['!14400', '<20000'] -> (size != 14400) AND (size < 20000) Returns ------- - response : list of `~requests.Response` + response : `~astropy.table.Table` + A table containing the query results. """ + # Parse pagination params + limit, offset = self._parse_legacy_pagination(limit, offset, pagesize, page) + + # Should not specify both region and coordinates + if coordinates and region: + raise InvalidQueryError("Specify either `region` or `coordinates`, not both.") + + # Should not specify both region and object_name + if object_name and region: + raise InvalidQueryError("Specify either `region` or `object_name`, not both.") + + collection_obj, catalog = self._parse_inputs(collection, catalog) + + # Check for conflicts between named parameters and filters dict + if criteria and filters: + overlap = set(k.lower() for k in criteria) & set(k.lower() for k in filters) + if overlap: + raise InvalidQueryError( + f"Criteria specified both as keyword arguments and in 'filters' for columns: " + f"{', '.join(sorted(overlap))}" + ) - # Put coordinates and radius into consistent format - coordinates = commons.parse_coordinates(coordinates, return_frame='icrs') + # Merge criteria from named parameters and filters dict + search_criteria = {} + search_criteria.update(criteria) + if filters: + search_criteria.update(filters) + + # Validate sort_by columns together with criteria keys so all column checks go through one path + validation_criteria = dict(search_criteria) + if sort_by: + sort_by = [sort_by] if isinstance(sort_by, str) else list(sort_by) + validation_criteria.update({col: True for col in sort_by}) + + collection_obj._verify_criteria(catalog, **validation_criteria) + catalog_metadata = collection_obj.get_catalog_metadata(catalog) + column_metadata = catalog_metadata.column_metadata + columns = "*" if not select_cols else self._parse_select_cols(select_cols, column_metadata) + + adql = ( + f"SELECT TOP {limit} {columns} FROM {catalog.lower()} " + if not count_only + else f"SELECT TOP 1 COUNT(*) AS count_all FROM {catalog.lower()} " + ) + has_where = False + if region or coordinates or object_name: + # Check if the catalog supports spatial queries + if not catalog_metadata.supports_spatial_queries: + raise InvalidQueryError( + f"Catalog '{catalog}' in collection '{collection_obj.name}' does not support spatial queries." + ) - # if radius is just a number we assume degrees - radius = coord.Angle(radius, u.deg) + # Positional query + adql_region = "" + if region: + adql_region = self._create_adql_region(region) + if object_name or coordinates: # Cone search + coordinates = utils.parse_input_location( + coordinates=coordinates, object_name=object_name, resolver=resolver + ) + radius = coord.Angle(radius, u.deg) # If radius is just a number we assume degrees + adql_region = f"CIRCLE('ICRS', {coordinates.ra.deg}, {coordinates.dec.deg}, {radius.to(u.deg).value})" + + region_types = ["POLYGON", "CIRCLE"] + for region_type in region_types: + if region_type in adql_region and region_type not in collection_obj.supported_adql_functions: + raise InvalidQueryError( + f"Catalog '{catalog}' in collection '{collection_obj.name}' " + f"does not support ADQL region type '{region_type}'." + ) - # basic params - params = {'ra': coordinates.ra.deg, - 'dec': coordinates.dec.deg, - 'radius': radius.deg} + # Get RA/Dec column names + ra_col = catalog_metadata.ra_column + dec_col = catalog_metadata.dec_column + adql += f"WHERE CONTAINS(POINT('ICRS', {ra_col}, {dec_col}), {adql_region}) = 1 " + has_where = True + + # Add additional constraints + if search_criteria: + conditions = self._format_criteria_conditions(collection_obj, catalog, search_criteria) + if has_where: + adql += "AND " + " AND ".join(conditions) + else: + adql += "WHERE " + " AND ".join(conditions) - # Determine API connection and service name - if catalog.lower() in self._service_api_connection.SERVICES: - self._current_connection = self._service_api_connection - service = catalog + # Add sorting if specified + if sort_by: + # Add ORDER BY clause + if isinstance(sort_desc, bool): + sort_desc = [sort_desc] - # validate user criteria - self._validate_service_criteria(catalog.lower(), **criteria) + if len(sort_desc) not in (1, len(sort_by)): + raise InvalidQueryError("Length of 'sort_desc' must be 1 or equal to length of 'sort_by'.") - # adding additional user specified parameters - for prop, value in criteria.items(): - params[prop] = value + if len(sort_desc) == 1: + sort_desc = sort_desc * len(sort_by) - else: - self._current_connection = self._portal_api_connection + order_parts = [ + f"{col} {'DESC' if desc else 'ASC'}" + for col, desc in zip(sort_by, sort_desc) + ] - # valid criteria keywords - valid_criteria = [] + adql += " ORDER BY " + ", ".join(order_parts) - # Sorting out the non-standard portal service names - if catalog.lower() == "hsc": - if version == 2: - service = "Mast.Hsc.Db.v2" - else: - if version not in (3, None): - warnings.warn("Invalid HSC version number, defaulting to v3.", InputWarning) - service = "Mast.Hsc.Db.v3" - - # Hsc specific parameters (can be overridden by user) - self.catalog_limit = criteria.pop('nr', 50000) - valid_criteria = ['nr', 'ni', 'magtype'] - params['nr'] = self.catalog_limit - params['ni'] = criteria.pop('ni', 1) - params['magtype'] = criteria.pop('magtype', 1) - - elif catalog.lower() == "galex": - service = "Mast.Galex.Catalog" - self.catalog_limit = criteria.get('maxrecords', 50000) - - # galex specific parameters (can be overridden by user) - valid_criteria = ['maxrecords'] - params['maxrecords'] = criteria.pop('maxrecords', 50000) - - elif catalog.lower() == "gaia": - if version == 1: - service = "Mast.Catalogs.GaiaDR1.Cone" - else: - if version not in (None, 2): - warnings.warn("Invalid Gaia version number, defaulting to DR2.", InputWarning) - service = "Mast.Catalogs.GaiaDR2.Cone" + # Add offset + if offset: + adql += f" OFFSET {offset}" - elif catalog.lower() == 'plato': - if version in (None, 1): - service = "Mast.Catalogs.Plato.Cone" - else: - warnings.warn("Invalid PLATO catalog version number, defaulting to DR1.", InputWarning) - service = "Mast.Catalogs.Plato.Cone" + if return_adql: + return adql - else: - service = "Mast.Catalogs." + catalog + ".Cone" - self.catalog_limit = None + # Execute the query + result_table = collection_obj.run_tap_query(adql, run_async=run_async) - # additional user-specified parameters are not valid - if criteria: - key = next(iter(criteria)) - closest_match = difflib.get_close_matches(key, valid_criteria, n=1) - error_msg = ( - f"Filter '{key}' does not exist for catalog {catalog}. Did you mean '{closest_match[0]}'?" - if closest_match - else f"Filter '{key}' does not exist for catalog {catalog}." - ) - raise InvalidQueryError(error_msg) + # Add ADQL to table metadata for reference + result_table.meta["adql_query"] = adql - # Parameters will be passed as JSON objects only when accessing the PANSTARRS API - use_json = catalog.lower() == 'panstarrs' + if len(result_table) == 0: + warnings.warn("The query returned no results.", NoResultsWarning) - return self._current_connection.service_request_async(service, params, pagesize=pagesize, page=page, - use_json=use_json) + if count_only: + return int(result_table["count_all"][0]) + else: + # TODO: Add schema browser URL to the result table metadata when available + result_table.meta["collection"] = collection_obj.name + result_table.meta["catalog"] = catalog + return result_table @class_or_instance - @deprecated_renamed_argument('objectname', 'object_name', since='0.4.12') - def query_object_async(self, object_name, *, radius=0.2*u.deg, catalog="Hsc", - pagesize=None, page=None, version=None, resolver=None, **criteria): + @deprecated_renamed_argument( + "version", + None, + since="0.4.13", + message="The `version` argument is deprecated and " + "will be removed in a future release. Please use `collection` and `catalog` instead.", + ) + @deprecated_renamed_argument( + "pagesize", + None, + since="0.4.13", + message="The `pagesize` argument is deprecated " + "and will be removed in a future release. Please use `limit` instead.", + ) + @deprecated_renamed_argument( + "page", + None, + since="0.4.13", + message="The `page` argument is deprecated " + "and will be removed in a future release. Please use `offset` instead.", + ) + def query_region( + self, + coordinates=None, + *, + radius=0.2 * u.deg, + region=None, + collection=None, + catalog=None, + limit=5000, + offset=0, + count_only=False, + select_cols=None, + sort_by=None, + sort_desc=False, + filters=None, + run_async=False, + return_adql=False, + version=None, + pagesize=None, + page=None, + **criteria, + ): """ - Given an object name, returns a list of catalog entries. - See column documentation for specific catalogs `here `__. + Query for MAST catalog entries within a specified region using criteria filters. To return columns for a given + collection and catalog, use `~astroquery.mast.CatalogsClass.get_column_metadata`. Parameters ---------- - object_name : str - The name of the target around which to search. + coordinates : str or `~astropy.coordinates` object, optional + The target around which to search. It may be specified as a string (e.g., '350 -80') or as an + Astropy coordinates object. radius : str or `~astropy.units.Quantity` object, optional - Default 0.2 degrees. - The string must be parsable by `~astropy.coordinates.Angle`. - The appropriate `~astropy.units.Quantity` object from - `~astropy.units` may also be used. Defaults to 0.2 deg. + The search radius around the target coordinates or object. Default 0.2 degrees. + region : str | iterable | `~regions.CircleSkyRegion` | `~regions.PolygonSkyRegion`, optional + The region to search within. It may be specified as a STC-S POLYGON or CIRCLE string + (e.g., 'CIRCLE 350 -80 0.2'), an iterable of coordinate pairs, or as an + `~regions.CircleSkyRegion` or `~regions.PolygonSkyRegion`. + collection : str, optional + The collection to be queried. If None, uses the instance's `collection` attribute. catalog : str, optional - Default HSC. - The catalog to be queried. + The catalog within the collection to query. If None, uses the instance's `catalog` attribute. + limit : int, optional + The maximum number of results to return. Default is 5000. + offset : int, optional + The number of rows to skip before starting to return rows. Default is 0. + count_only : bool, optional + If True, only return the count of matching records instead of the records themselves. Default is False. + select_cols : list of str, optional + List of column names to include in the result. If None or empty, all columns are returned. + sort_by : str or list of str, optional + Column name(s) to sort the results by. + sort_desc : bool or list of bool, optional + Indicates whether to sort in descending order for each column in ``sort_by``. If a single bool, + applies to all columns. If a list, must match length of ``sort_by``. Default is False (ascending order). + filters : dict, optional + Another parameter to specify criteria filters as a dictionary. Use this option when the name of a column + conflicts with a named parameter of this method. + run_async : bool, optional + If True, run the query in asynchronous mode. This mode is more robust and preferable + for long-running queries. If you encounter timeouts or connection issues with large queries, + set this to True. Default is False (synchronous mode). + return_adql : bool, optional + If True, return the ADQL query string instead of executing the query. Default is False. + When False, the ADQL query string is also returned in the metadata of the result table. + version : str, optional + Deprecated. The version argument is no longer used. Please use ``collection`` and ``catalog`` instead. pagesize : int, optional - Default None. - Can be used to override the default pagesize for (set in configs) this query only. - E.g. when using a slow internet connection. + Deprecated. The pagesize argument is no longer used. Please use ``limit`` instead. page : int, optional - Default None. - Can be used to override the default behavior of all results being returned - to obtain a specific page of results. - version : int, optional - Version number for catalogs that have versions. Default is highest version. - resolver : str, optional - The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". - If not specified, the default resolver order will be used. Please see the - `STScI Archive Name Translation Application (SANTA) `__ - for more information. Default is None. + Deprecated. The page argument is no longer used. Please use ``offset`` instead. **criteria - Catalog-specific keyword args. - These can be found in the `service documentation `__. - for specific catalogs. For example, one can specify the magtype for an HSC search. - For catalogs available through Catalogs.MAST (PanSTARRS), the Column Name is the keyword, and the argument - should be either an acceptable value for that parameter, or a list consisting values, or tuples of - decorator, value pairs (decorator, value). In addition, columns may be used to select the return columns, - consisting of a list of column names. Results may also be sorted through the query with the parameter - sort_by composed of either a single Column Name to sort ASC, or a list of Column Nmaes to sort ASC or - tuples of Column Name and Direction (ASC, DESC) to indicate sort order (Column Name, DESC). - Detailed information of Catalogs.MAST criteria usage can - be found `here `__. + Keyword arguments representing criteria filters to apply. + + Criteria syntax: + + - Strings support wildcards using '*' (converted to SQL '%') and '%'. + - Lists are combined with OR for positive values; empty lists yield no matches. + - Numeric columns support comparison operators ('<', '<=', '>', '>=') and inclusive ranges using + the syntax 'low..high' (e.g., '5..10'). Mixed lists of numbers and comparisons are OR-combined. + - Negation: Prefix any value with '!' to negate that predicate. For list inputs, all negated values + for the same column are AND-combined, then ANDed with the OR of the positive values: + (neg1 AND neg2 AND ...) AND (pos1 OR pos2 OR ...). + - Temporal columns can be specified as strings in a recognized date/time format (e.g., 'YYYY', 'YYYY-MM-DD', + 'YYYY-MM-DD hh:mm:ss', etc.), `~astropy.time.Time` objects, or `datetime` objects. The same comparison + operators and range syntax as numeric columns can be used to filter temporal columns + based on date/time values. + + Examples: + + - file_suffix=['A', 'B', '!C'] -> (file_suffix != 'C') AND (file_suffix IN ('A', 'B')) + - size=['!14400', '<20000'] -> (size != 14400) AND (size < 20000) Returns ------- - response : list of `~requests.Response` + response : `~astropy.table.Table` + A table containing the query results. """ - - coordinates = utils.resolve_object(object_name, resolver=resolver) - - return self.query_region_async(coordinates, - radius=radius, - catalog=catalog, - version=version, - pagesize=pagesize, - page=page, - **criteria) + # Must specify one of region or coordinates + if region is None and coordinates is None: + raise InvalidQueryError( + "Must specify either `region` or `coordinates`. For non-positional queries, " + "use `Catalogs.query_criteria`." + ) + + # Parse pagination params + limit, offset = self._parse_legacy_pagination(limit, offset, pagesize, page) + + return self.query_criteria( + collection=collection, + catalog=catalog, + coordinates=coordinates, + region=region, + radius=radius, + limit=limit, + offset=offset, + count_only=count_only, + select_cols=select_cols, + sort_by=sort_by, + sort_desc=sort_desc, + filters=filters, + run_async=run_async, + return_adql=return_adql, + **criteria, + ) @class_or_instance - def query_criteria_async(self, catalog, *, pagesize=None, page=None, resolver=None, **criteria): + @deprecated_renamed_argument( + "version", + None, + since="0.4.13", + message="The `version` argument is deprecated and " + "will be removed in a future release. Please use `collection` and `catalog` instead.", + ) + @deprecated_renamed_argument( + "pagesize", + None, + since="0.4.13", + message="The `pagesize` argument is deprecated " + "and will be removed in a future release. Please use `limit` instead.", + ) + @deprecated_renamed_argument( + "page", + None, + since="0.4.13", + message="The `page` argument is deprecated " + "and will be removed in a future release. Please use `offset` instead.", + ) + @deprecated_renamed_argument("objectname", "object_name", since="0.4.13") + def query_object( + self, + object_name, + *, + radius=0.2 * u.deg, + collection=None, + catalog=None, + resolver=None, + limit=5000, + offset=0, + count_only=False, + select_cols=None, + sort_by=None, + sort_desc=False, + filters=None, + run_async=False, + return_adql=False, + version=None, + pagesize=None, + page=None, + **criteria, + ): """ - Given an set of filters, returns a list of catalog entries. - See column documentation for specific catalogs `here `__. + Query for MAST catalog entries around a specified object name using criteria filters. To return columns + for a given collection and catalog, use `~astroquery.mast.CatalogsClass.get_column_metadata`. Parameters ---------- - catalog : str - The catalog to be queried. + object_name : str, optional + The name of the object to resolve and search around. + radius : str or `~astropy.units.Quantity` object, optional + The search radius around the target coordinates or object. Default 0.2 degrees. + collection : str, optional + The collection to be queried. If None, uses the instance's `collection` attribute. + catalog : str, optional + The catalog within the collection to query. If None, uses the instance's `catalog` attribute. + resolver : str, optional + The name resolver service to use when resolving ``object_name``. + limit : int, optional + The maximum number of results to return. Default is 5000. + offset : int, optional + The number of rows to skip before starting to return rows. Default is 0. + count_only : bool, optional + If True, only return the count of matching records instead of the records themselves. Default is False. + select_cols : list of str, optional + List of column names to include in the result. If None or empty, all columns are returned. + sort_by : str or list of str, optional + Column name(s) to sort the results by. + sort_desc : bool or list of bool, optional + Indicates whether to sort in descending order for each column in ``sort_by``. If a single bool, + applies to all columns. If a list, must match length of ``sort_by``. Default is False (ascending order). + filters : dict, optional + Another parameter to specify criteria filters as a dictionary. Use this option when the name of a column + conflicts with a named parameter of this method. + run_async : bool, optional + If True, run the query in asynchronous mode. This mode is more robust and preferable + for long-running queries. If you encounter timeouts or connection issues with large queries, + set this to True. Default is False (synchronous mode). + return_adql : bool, optional + If True, return the ADQL query string instead of executing the query. Default is False. + When False, the ADQL query string is also returned in the metadata of the result table. + version : str, optional + Deprecated. The version argument is no longer used. Please use ``collection`` and ``catalog`` instead. pagesize : int, optional - Can be used to override the default pagesize. - E.g. when using a slow internet connection. + Deprecated. The pagesize argument is no longer used. Please use ``limit`` instead. page : int, optional - Can be used to override the default behavior of all results being returned to obtain - one specific page of results. - resolver : str, optional - The resolver to use when resolving a named target into coordinates. Valid options are "SIMBAD" and "NED". - If not specified, the default resolver order will be used. Please see the - `STScI Archive Name Translation Application (SANTA) `__ - for more information. Default is None. + Deprecated. The page argument is no longer used. Please use ``offset`` instead. **criteria - Criteria to apply. At least one non-positional criteria must be supplied. - Valid criteria are coordinates, object_name, radius (as in `query_region` and `query_object`), - and all fields listed in the column documentation for the catalog being queried. - The Column Name is the keyword, with the argument being one or more acceptable values for that parameter, - except for fields with a float datatype where the argument should be in the form [minVal, maxVal]. - For non-float type criteria wildcards maybe used (both * and % are considered wildcards), however - only one wildcarded value can be processed per criterion. - RA and Dec must be given in decimal degrees, and datetimes in MJD. - For example: filters=["FUV","NUV"],proposal_pi="Ost*",t_max=[52264.4586,54452.8914] - For catalogs available through Catalogs.MAST (PanSTARRS), the Column Name is the keyword, and the argument - should be either an acceptable value for that parameter, or a list consisting values, or tuples of - decorator, value pairs (decorator, value). In addition, columns may be used to select the return columns, - consisting of a list of column names. Results may also be sorted through the query with the parameter - sort_by composed of either a single Column Name to sort ASC, or a list of Column Nmaes to sort ASC or - tuples of Column Name and Direction (ASC, DESC) to indicate sort order (Column Name, DESC). - Detailed information of Catalogs.MAST criteria usage can - be found `here `__. - - Returns - ------- - response : list of `~requests.Response` - """ - - # Separating any position info from the rest of the filters - coordinates = criteria.pop('coordinates', None) - object_name = criteria.pop('object_name', None) - radius = criteria.pop('radius', 0.2*u.deg) - - if object_name or coordinates: - coordinates = utils.parse_input_location(coordinates=coordinates, - object_name=object_name, - resolver=resolver) - - # if radius is just a number we assume degrees - radius = coord.Angle(radius, u.deg) - - # build query - params = {} - if coordinates: - params["ra"] = coordinates.ra.deg - params["dec"] = coordinates.dec.deg - params["radius"] = radius.deg - - # Determine API connection, service name, and build filter set - filters = None - if catalog.lower() in self._service_api_connection.SERVICES: - self._current_connection = self._service_api_connection - service = catalog - - # validate user criteria - self._validate_service_criteria(catalog.lower(), **criteria) - - if not self._current_connection.check_catalogs_criteria_params(criteria): - raise InvalidQueryError("At least one non-positional criterion must be supplied.") + Keyword arguments representing criteria filters to apply. - for prop, value in criteria.items(): - params[prop] = value + Criteria syntax: - else: - self._current_connection = self._portal_api_connection - - if catalog.lower() == "tic": - service = "Mast.Catalogs.Filtered.Tic" - if coordinates or object_name: - service += ".Position" - service += ".Rows" # Using the rowstore version of the query for speed - column_config_name = "Mast.Catalogs.Tess.Cone" - params["columns"] = "*" - elif catalog.lower() == "ctl": - service = "Mast.Catalogs.Filtered.Ctl" - if coordinates or object_name: - service += ".Position" - service += ".Rows" # Using the rowstore version of the query for speed - column_config_name = "Mast.Catalogs.Tess.Cone" - params["columns"] = "*" - elif catalog.lower() == "diskdetective": - service = "Mast.Catalogs.Filtered.DiskDetective" - if coordinates or object_name: - service += ".Position" - column_config_name = "Mast.Catalogs.Dd.Cone" - else: - raise InvalidQueryError("Criteria query not available for {}".format(catalog)) + - Strings support wildcards using '*' (converted to SQL '%') and '%'. + - Lists are combined with OR for positive values; empty lists yield no matches. + - Numeric columns support comparison operators ('<', '<=', '>', '>=') and inclusive ranges using + the syntax 'low..high' (e.g., '5..10'). Mixed lists of numbers and comparisons are OR-combined. + - Negation: Prefix any value with '!' to negate that predicate. For list inputs, all negated values + for the same column are AND-combined, then ANDed with the OR of the positive values: + (neg1 AND neg2 AND ...) AND (pos1 OR pos2 OR ...). + - Temporal columns can be specified as strings in a recognized date/time format (e.g., 'YYYY', 'YYYY-MM-DD', + 'YYYY-MM-DD hh:mm:ss', etc.), `~astropy.time.Time` objects, or `datetime` objects. The same comparison + operators and range syntax as numeric columns can be used to filter temporal columns + based on date/time values. - filters = self._current_connection.build_filter_set(column_config_name, service, **criteria) + Examples: - if not filters: - raise InvalidQueryError("At least one non-positional criterion must be supplied.") - params["filters"] = filters + - file_suffix=['A', 'B', '!C'] -> (file_suffix != 'C') AND (file_suffix IN ('A', 'B')) + - size=['!14400', '<20000'] -> (size != 14400) AND (size < 20000) - # Parameters will be passed as JSON objects only when accessing the PANSTARRS API - use_json = catalog.lower() == 'panstarrs' - - return self._current_connection.service_request_async(service, params, pagesize=pagesize, page=page, - use_json=use_json) + Returns + ------- + response : `~astropy.table.Table` + A table containing the query results. + """ + # Parse pagination params + limit, offset = self._parse_legacy_pagination(limit, offset, pagesize, page) + + return self.query_criteria( + collection=collection, + catalog=catalog, + object_name=object_name, + radius=radius, + resolver=resolver, + limit=limit, + offset=offset, + count_only=count_only, + select_cols=select_cols, + sort_by=sort_by, + sort_desc=sort_desc, + filters=filters, + run_async=run_async, + return_adql=return_adql, + **criteria, + ) @class_or_instance + @deprecated(since="v0.4.13", message=("This function is deprecated and will be removed in a future release.")) def query_hsc_matchid_async(self, match, *, version=3, pagesize=None, page=None): """ Returns all the matches for a given Hubble Source Catalog MatchID. @@ -499,11 +758,10 @@ def query_hsc_matchid_async(self, match, *, version=3, pagesize=None, page=None) ------- response : list of `~requests.Response` """ - self._current_connection = self._portal_api_connection if isinstance(match, Row): - match = match["MatchID"] + match = match["MatchID"] if "MatchID" in match.colnames else match["matchid"] match = str(match) # np.int64 gives json serializer problems, so stringify right here if version == 2: @@ -515,9 +773,10 @@ def query_hsc_matchid_async(self, match, *, version=3, pagesize=None, page=None) params = {"input": match} - return self._current_connection.service_request_async(service, params, pagesize=pagesize, page=page) + return self._current_connection.service_request_async(service, params, pagesize, page) @class_or_instance + @deprecated(since="v0.4.13", message=("This function is deprecated and will be removed in a future release.")) def get_hsc_spectra_async(self, *, pagesize=None, page=None): """ Returns all Hubble Source Catalog spectra. @@ -535,14 +794,10 @@ def get_hsc_spectra_async(self, *, pagesize=None, page=None): ------- response : list of `~requests.Response` """ - self._current_connection = self._portal_api_connection + return self._current_connection.service_request_async("Mast.HscSpectra.Db.All", {}, pagesize, page) - service = "Mast.HscSpectra.Db.All" - params = {} - - return self._current_connection.service_request_async(service, params, pagesize, page) - + @deprecated(since="v0.4.13", message=("This function is deprecated and will be removed in a future release.")) def download_hsc_spectra(self, spectra, *, download_dir=None, cache=True, curl_flag=False): """ Download one or more Hubble Source Catalog spectra. @@ -566,108 +821,828 @@ def download_hsc_spectra(self, spectra, *, download_dir=None, cache=True, curl_f ------- response : list of `~requests.Response` """ - - # if spectra is not a Table, put it in a list + # Normalize spectra input to a list if isinstance(spectra, Row): spectra = [spectra] - # set up the download directory and paths - if not download_dir: - download_dir = '.' + # Ensure download directory is set + download_dir = download_dir or "." - if curl_flag: # don't want to download the files now, just the curl script + if curl_flag: + timestamp = time.strftime("%Y%m%d%H%M%S") + bundle_name = "mastDownload_" + timestamp + url_list = [self._make_data_url(spec) for spec in spectra] + path_list = [f"{bundle_name}/HSC/{spec['DatasetName']}.fits" for spec in spectra] - download_file = "mastDownload_" + time.strftime("%Y%m%d%H%M%S") + params = dict( + urlList=",".join(url_list), + filename=bundle_name, + pathList=",".join(path_list), + descriptionList=[""] * len(spectra), + productTypeList=["spectrum"] * len(spectra), + extension="curl", + ) - url_list = [] - path_list = [] - for spec in spectra: - if spec['SpectrumType'] < 2: - url_list.append('https://hla.stsci.edu/cgi-bin/getdata.cgi?config=ops&dataset={0}' - .format(spec['DatasetName'])) + service = "Mast.Bundle.Request" + response = self._portal_api_connection.service_request_async(service, params) + bundle_info = response[0].json() + local_script = os.path.join(download_dir, f"{bundle_name}.sh") + self._download_file(bundle_info["url"], local_script, head_safe=True) + + # Build manifest row + exists = os.path.isfile(local_script) + missing = [k for k, v in bundle_info.get("statusList", {}).items() if v != "COMPLETE"] + manifest = Table( + { + "Local Path": [local_script], + "Status": ["COMPLETE" if exists else "ERROR"], + "Message": [ + None + if exists and not missing + else ( + f"{len(missing)} files could not be added to curl script" + if exists + else "Curl script could not be downloaded" + ) + ], + "URL": [None if exists and not missing else (",".join(missing) if missing else bundle_info["url"])], + } + ) + return manifest + + base_dir = os.path.join(download_dir, "mastDownload", "HSC") + os.makedirs(base_dir, exist_ok=True) + manifest_rows = [] + + for row in spectra: + dataset = row["DatasetName"] + url = self._make_data_url(row) + local_path = os.path.join(base_dir, f"{dataset}.fits") + status = "COMPLETE" + message = None - else: - url_list.append('https://hla.stsci.edu/cgi-bin/ecfproxy?file_id={0}' - .format(spec['DatasetName']) + '.fits') + try: + self._download_file(url, local_path, cache=cache, head_safe=True) - path_list.append(download_file + "/HSC/" + spec['DatasetName'] + '.fits') + if not os.path.exists(local_path): + status = "ERROR" + message = "File was not downloaded" + except requests.HTTPError as err: + status = "ERROR" + message = f"HTTPError: {err}" - description_list = [""]*len(spectra) - producttype_list = ['spectrum']*len(spectra) + manifest_rows.append([local_path, status, message, url]) - service = "Mast.Bundle.Request" - params = {"urlList": ",".join(url_list), - "filename": download_file, - "pathList": ",".join(path_list), - "descriptionList": list(description_list), - "productTypeList": list(producttype_list), - "extension": 'curl'} + return Table(rows=manifest_rows, names=("Local Path", "Status", "Message", "URL")) - response = self._portal_api_connection.service_request_async(service, params) - bundler_response = response[0].json() + def _parse_result(self, response, *, verbose=False): + """Parse the async responses from HSC queries.""" + return self._current_connection._parse_result(response, verbose=verbose) - local_path = os.path.join(download_dir, "{}.sh".format(download_file)) - self._download_file(bundler_response['url'], local_path, head_safe=True) + def _verify_collection(self, collection): + """ + Verify that the specified collection is valid and return the correct collection name. + Warns the user if the collection has been renamed and raises an error if the collection is not valid. - status = "COMPLETE" - msg = None - url = None + Parameters + ---------- + collection : str + The collection to be verified. - if not os.path.isfile(local_path): - status = "ERROR" - msg = "Curl could not be downloaded" - url = bundler_response['url'] + Raises + ------ + InvalidQueryError + If the specified collection is not valid. + """ + collection = collection.lower().strip() + if collection in self.available_collections: + return collection + else: + if collection in self._renamed_collections: + new_name = self._renamed_collections[collection] + warn_msg = f"Collection '{collection}' has been renamed. Please use '{new_name}' instead." + warnings.warn(warn_msg, InputWarning) + return new_name + + error_msg = "" + if collection in self._no_longer_supported_collections: + error_msg = ( + f"Collection '{collection}' is no longer supported. To query from this catalog, " + f"please use a version of Astroquery older than 0.4.13. You can install version 0.4.12 using " + f"`pip install astroquery==0.4.12`" + ) else: - missing_files = [x for x in bundler_response['statusList'].keys() - if bundler_response['statusList'][x] != 'COMPLETE'] - if len(missing_files): - msg = "{} files could not be added to the curl script".format(len(missing_files)) - url = ",".join(missing_files) + closest = difflib.get_close_matches(collection, self.available_collections, n=1) + suggestion = f" Did you mean '{closest[0]}'?" if closest else "" + error_msg = f"Collection '{collection}' is not recognized.{suggestion}" + error_msg += " Available collections are: " + ", ".join(self.available_collections) + raise InvalidQueryError(error_msg) - manifest = Table({'Local Path': [local_path], - 'Status': [status], - 'Message': [msg], - "URL": [url]}) + def _get_collection_obj(self, collection_name): + """ + Given a collection name, find or create the corresponding CatalogCollection object. + + Parameters + ---------- + collection_name : str + The name of the collection. + + Returns + ------- + CatalogCollection + The corresponding CatalogCollection object. + """ + if not isinstance(collection_name, str): + raise InvalidQueryError("Collection name must be a string.") + + collection_name = collection_name.lower().strip() + if collection_name in self._collections_cache: + return self._collections_cache[collection_name] + + collection_name = self._verify_collection(collection_name) + collection_obj = CatalogCollection(collection_name) + self._collections_cache[collection_name] = collection_obj + return collection_obj + + def _parse_inputs(self, collection=None, catalog=None): + """ + Parse and validate the collection and catalog inputs. + + Parameters + ---------- + collection : str, optional + The collection to be queried. If None, uses the instance's default collection. + catalog : str, optional + The catalog within the collection to query. If None, uses the instance's default catalog. + + Returns + ------- + tuple + A tuple containing the (collection, catalog) to be queried. + """ + collection_obj = self._get_collection_obj(collection) if collection else self._collection + if not catalog: + # If the class attribute catalog is valid for the collection, use it + # Otherwise, use the default catalog for the collection + if self.catalog in collection_obj.catalog_names: + catalog = self.catalog + else: + catalog = collection_obj.default_catalog else: - base_dir = download_dir.rstrip('/') + "/mastDownload/HSC" + catalog = catalog.lower() + # For backwards compatibility, check if the user is trying to specify a collection via catalog + if ( + catalog in self.available_collections + or catalog in self._no_longer_supported_collections + or catalog in self._renamed_collections + ) and not collection: + warnings.warn( + f"Specifying collection '{catalog}' via the `catalog` parameter is deprecated. " + f"Please use the `collection` parameter instead.", + DeprecationWarning, + ) + # As a convenience to the user, set the collection accordingly and use its default catalog + collection_obj = self._get_collection_obj(catalog) + catalog = collection_obj.default_catalog + else: + catalog = collection_obj._verify_catalog(catalog) - if not os.path.exists(base_dir): - os.makedirs(base_dir) + return collection_obj, catalog - manifest_array = [] - for spec in spectra: + def _parse_select_cols(self, select_cols, column_metadata): + """ + Validate and parse the select_cols parameter. - if spec['SpectrumType'] < 2: - data_url = f'https://hla.stsci.edu/cgi-bin/getdata.cgi?config=ops&dataset={spec["DatasetName"]}' - else: - data_url = f'https://hla.stsci.edu/cgi-bin/ecfproxy?file_id={spec["DatasetName"]}.fits' + Parameters + ---------- + select_cols : list of str + List of column names to include in the result. + column_metadata : `~astropy.table.Table` + The catalog's column metadata table. + + Returns + ------- + str + Comma-separated string of valid column names for ADQL SELECT clause. + + Raises + ------ + InvalidQueryError + If any specified column is not found in the catalog metadata. + """ + valid_columns = column_metadata["column_name"].tolist() + valid_selected = [] + for col in select_cols: + if col not in valid_columns: + closest = difflib.get_close_matches(col, valid_columns, n=1) + suggestion = f" Did you mean '{closest[0]}'?" if closest else "" + warnings.warn(f"Column '{col}' not found in catalog.{suggestion}", InputWarning) + else: + valid_selected.append(col) + if not valid_selected: + raise InvalidQueryError("No valid columns specified in `select_cols`.") + return ", ".join(valid_selected) + + def _parse_legacy_pagination(self, limit, offset, pagesize, page): + """ + Parse legacy pagesize and page parameters to determine limit and offset. + + Parameters + ---------- + limit : int + The maximum number of results to return. + offset : int + The number of rows to skip before starting to return rows. + pagesize : int, optional + The number of results per page (legacy parameter). + page : int, optional + The page number to return (legacy parameter). + + Returns + ------- + tuple + A tuple containing the (limit, offset) values. + """ + # If limit and offset are default, check for legacy pagination params + if limit == 5000 and offset == 0: + if pagesize is not None: + if page is None: + page = 1 # Default to first page if not specified + limit = pagesize + offset = (page - 1) * pagesize + elif page is not None: + warnings.warn( + "The 'page' parameter is ignored without 'pagesize'. " + "Please use `limit` and `offset` to specify pagination.", + InputWarning, + ) + return limit, offset + + def _create_adql_region(self, region): + """ + Returns the ADQL description of the given polygon or circle region. + + Parameters + ---------- + region : str | iterable | astropy.regions.Region + - Iterable of RA/Dec pairs as lists/sequences + - STC-S POLYGON or CIRCLE string + - `~astropy.regions.CircleSkyRegion` or `~astropy.regions.PolygonSkyRegion` + + Returns + ------- + adql_region : str + ADQL representation of the region (POLYGON or CIRCLE) + """ + # Case 1: region is a string (e.g. STC-S syntax) + if isinstance(region, str): + parts = region.strip().lower().split() + shape = parts[0] + + if shape == "polygon": + # POLYGON lon1 lat1 lon2 lat2 ... + # Optional format: POLYGON ICRS lon1 lat1 ... + # parts = ["POLYGON", maybe_frame?, ...coords...] + + # Determine if parts[1] is a coord or a frame name + if len(parts) < 3: + raise InvalidQueryError(f"Invalid POLYGON region string: {region}") + try: + float(parts[1]) # numeric → no frame name + point_parts = parts[1:] + except ValueError: + point_parts = parts[2:] # skip optional frame name + + if len(point_parts) < 6 or len(point_parts) % 2 != 0: + # polygon requires at least 3 points (6 numbers), and must be pairs + raise InvalidQueryError(f"Invalid POLYGON region string: {region}") - local_path = os.path.join(base_dir, f'{spec["DatasetName"]}.fits') + point_string = ",".join(point_parts) + return f"POLYGON('ICRS',{point_string})" - status = "COMPLETE" - msg = None - url = None + elif shape == "circle": + # CIRCLE ra dec radius (or CIRCLE ICRS ra dec radius) + if len(parts) < 4: + raise InvalidQueryError(f"Invalid CIRCLE region string: {region}") + # Try interpreting parts[1] as RA. If not numeric, assume it's a frame try: - self._download_file(data_url, local_path, cache=cache, head_safe=True) + float(parts[1]) + # Format: CIRCLE ra dec radius + ra, dec, radius = parts[1], parts[2], parts[3] + except ValueError: + # Format: CIRCLE FRAME ra dec radius + if len(parts) < 5: + raise InvalidQueryError(f"Invalid CIRCLE region string: {region}") + ra, dec, radius = parts[2], parts[3], parts[4] - # check file size also this is where would perform md5 - if not os.path.isfile(local_path): - status = "ERROR" - msg = "File was not downloaded" - url = data_url + return f"CIRCLE('ICRS',{ra},{dec},{radius})" - except HTTPError as err: - status = "ERROR" - msg = "HTTPError: {0}".format(err) - url = data_url + else: + raise InvalidQueryError(f"Unrecognized region string: {region}") + + # Case 2: region is an astropy region object + # TODO: When released, change these to use `CircleSphericalSkyRegion` and `PolygonSphericalSkyRegion` + if HAS_REGIONS: + if isinstance(region, CircleSkyRegion): + center = region.center.icrs + radius = region.radius.to(u.deg).value + return f"CIRCLE('ICRS',{center.ra.deg},{center.dec.deg},{radius})" + elif isinstance(region, PolygonSkyRegion): + verts = region.vertices.icrs + point_string = ",".join(f"{v.ra.deg},{v.dec.deg}" for v in verts) + return f"POLYGON('ICRS',{point_string})" + + # Case 3: region is an iterable of coordinate pairs + if isinstance(region, Iterable): + # Expect something like [(ra1, dec1), (ra2, dec2), ...] + try: + points = [float(x) for point in region for x in point] + except Exception as e: + raise InvalidQueryError(f"Invalid iterable region format: {region}") from e + return f"POLYGON('ICRS',{','.join(str(x) for x in points)})" + + else: + raise TypeError(f"Unsupported region type: {type(region)}") + + def _classify_columns(self, meta): + """ + Classify columns as numeric or temporal based on their datatype and UCD metadata. + + Parameters + ---------- + meta : `~astropy.table.Table` + The catalog's column metadata table, which must include 'column_name', 'datatype', and 'ucd' columns. + + Returns + ------- + tuple + A tuple containing two sets: (numeric_columns, temporal_columns), where each set contains the names of the + columns classified as numeric or temporal, respectively. + """ + num_types = {"int", "long", "short", "float", "double", "floatcomplex", "doublecomplex"} + temporal_tokens = {"date", "time", "timestamp", "datetime"} + + numeric = set() + temporal = set() + + for name, dtype, ucd in zip(meta["column_name"], meta["datatype"], meta["ucd"]): + dtype_str = dtype.lower() if isinstance(dtype, str) else "" + ucd_str = ucd.lower() if isinstance(ucd, str) else "" + + # Classify as numeric if the datatype matches known numeric types + if dtype_str in num_types: + numeric.add(name) + + # Classify as temporal if the datatype contains temporal tokens or if the + # UCD indicates a time-related quantity (but exclude numeric types that may have time-related UCDs) + has_temporal_dtype = any(token in dtype_str for token in temporal_tokens) + has_temporal_ucd = ucd_str.startswith("time.") or ";time." in ucd_str + not_numeric = dtype_str not in num_types + + if has_temporal_dtype or (has_temporal_ucd and not_numeric): + temporal.add(name) + + return numeric, temporal + + def _quote_adql_string(self, adql_str): + """Escape single quotes in ADQL query strings by doubling them.""" + return adql_str.replace("'", "''") + + def _parse_range_cmp_expr(self, col, expr, value_formatter): + expr = expr.strip() - manifest_array.append([local_path, status, msg, url]) + range_match = re.fullmatch(r"(.+?)\s*\.\.\s*(.+)", expr) + if range_match: + low = value_formatter(range_match.group(1).strip(), col) + high = value_formatter(range_match.group(2).strip(), col) + return f"{col} BETWEEN {low} AND {high}" + + cmp_match = re.fullmatch(r"(<=|>=|<|>)\s*(.+)", expr) + if cmp_match: + rhs = value_formatter(cmp_match.group(2).strip(), col) + return f"{col} {cmp_match.group(1)} {rhs}" + + def _parse_numeric_expr(self, col, expr): + """ + Parse a numeric expression for a column and return the corresponding ADQL predicate. + + Parameters + ---------- + col : str + The column name. + expr : str + The numeric expression (e.g., "5", "<10", "5..10"). + + Returns + ------- + str + The ADQL predicate for the numeric expression. + """ + parsed = self._parse_range_cmp_expr(col, expr, lambda x, _: x) + if parsed: + return parsed + + try: + return f"{col} = {float(expr)}" + except ValueError: + raise InvalidQueryError( + f"Column '{col}' is numeric; unsupported value '{expr}'. Use numbers, comparisons like '<10', or " + "ranges like '5..10'." + ) + + def _normalize_time(self, value): + """ + Normalize a datetime value to a string format suitable for ADQL queries. + + Parameters + ---------- + value : str or `~astropy.time.Time` or `~datetime.datetime` + The datetime value to normalize. + + Returns + ------- + str + The normalized datetime string in the format 'YYYY-MM-DD HH:MM:SS' or the original + value if it cannot be parsed as a datetime. + + Raises + ------ + ValueError + If the value cannot be parsed as a datetime. + """ + t = Time(value) + dt = t.to_datetime() - manifest = Table(rows=manifest_array, names=('Local Path', 'Status', 'Message', "URL")) + # Drop microseconds to avoid ADQL parsing issues + if dt.microsecond: + dt = dt.replace(microsecond=0) - return manifest + return dt.strftime("%Y-%m-%d %H:%M:%S") + + def _to_temporal_literal(self, value, col): + """ + Convert a datetime value to an ADQL literal string, ensuring it is properly formatted and quoted. + + Parameters + ---------- + value : str or `~astropy.time.Time` or `~datetime.datetime` + The datetime value to convert. + col : str + The column name (used for error messages). + + Returns + ------- + str + The ADQL literal string representing the datetime value. + """ + try: + normalized = self._normalize_time(value) + except ValueError: + # If it can't be parsed as a time, send the value as-is for the backend to handle + # (which may raise an error if it's invalid) + normalized = value if isinstance(value, str) else str(value) + escaped = self._quote_adql_string(normalized) + return f"'{escaped}'" + + def _parse_temporal_expr(self, col, expr): + """ + Parse a datetime expression for a column and return the corresponding ADQL predicate. + + Parameters + ---------- + col : str + The column name. + expr : str + Datetime expression (e.g., "2024-01-01", ">=2024-01-01", "2024-01-01..2024-12-31"). + + Returns + ------- + str + The ADQL predicate for the datetime expression. + """ + if isinstance(expr, str): + parsed = self._parse_range_cmp_expr(col, expr, self._to_temporal_literal) + if parsed: + return parsed + + # For simple equality, expand to a small range + try: + t0 = Time(self._normalize_time(expr)) + t1 = t0 + 1 * u.s + low = self._quote_adql_string(t0.strftime("%Y-%m-%d %H:%M:%S")) + high = self._quote_adql_string(t1.strftime("%Y-%m-%d %H:%M:%S")) + return f"{col} BETWEEN '{low}' AND '{high}'" + except ValueError: + # If it can't be parsed as a time, send the value as-is for the backend to handle + # (which may raise an error if it's invalid) + return f"{col} = '{expr}'" + + def _format_scalar_predicate(self, col, val, is_numeric=False, is_temporal=False): + """ + Build predicate for a scalar value, aware of column type. + + Parameters + ---------- + col : str + The column name. + val : scalar + The value to build the predicate for. + is_numeric : bool + Whether the column is numeric. + is_temporal : bool + Whether the column is temporal. + + Returns + ------- + str + The ADQL predicate for the scalar value. + """ + if isinstance(val, bool): + # Booleans stored as integers + return f"{col} = {int(val)}" + + if is_temporal: + is_neg = isinstance(val, str) and val.startswith("!") + sval = val[1:].strip() if is_neg else val + parsed = self._parse_temporal_expr(col, sval) + return f"NOT ({parsed})" if is_neg else parsed + + if isinstance(val, str): + # Check for negation + is_neg = val.startswith("!") + sval = val[1:].strip() if is_neg else val + + # Strings for numeric columns + if is_numeric: + parsed = self._parse_numeric_expr(col, sval) + return f"NOT ({parsed})" if is_neg else parsed + + # Non-numeric strings + has_wild = ("*" in sval) or ("%" in sval) + pattern = self._quote_adql_string(sval.replace("*", "%")) + expr = f"{col} LIKE '{pattern}'" if has_wild else f"{col} = '{pattern}'" + return f"NOT ({expr})" if is_neg else expr + + # Numerics or others + return f"{col} = {val}" + + def _combine_predicates(self, pos_parts, neg_parts): + """ + Combine positive and negative predicate parts into a single ADQL expression. + + Parameters + ---------- + pos_parts : list of str + List of positive predicate strings. + neg_parts : list of str + List of negative predicate strings. + + Returns + ------- + str + The combined ADQL predicate. + """ + pos_expr = "" + if len(pos_parts) == 1: + pos_expr = pos_parts[0] + elif len(pos_parts) > 1: + pos_expr = "(" + " OR ".join(pos_parts) + ")" + + if neg_parts and pos_expr: + return "(" + " AND ".join(neg_parts) + ") AND " + pos_expr + if neg_parts: + return " AND ".join(neg_parts) + return pos_expr + + def _build_list_predicate(self, col, pos_items, neg_items, pos_builder, neg_builder): + """ + General helper to build predicates for list values with separate positive and negative handling. + + Parameters + ---------- + col : str + The column name. + pos_items : list + List of positive values. + neg_items : list + List of negative values. + pos_builder : function + Function to build positive predicates, called as pos_builder(col, pos_items). + neg_builder : function + Function to build negative predicates, called as neg_builder(col, neg_items). + + Returns + ------- + str + The combined ADQL predicate for the list values. + """ + pos_parts = pos_builder(col, pos_items) if pos_items else [] + neg_parts = [neg_builder(col, v) for v in neg_items] if neg_items else [] + return self._combine_predicates(pos_parts, neg_parts) + + def _build_numeric_list_predicate(self, col, pos_items, neg_items): + """ + Build predicate for multiple values passed into a numeric column with separated positives and negatives. + + Parameters + ---------- + col : str + The column name. + pos_items : list + List of positive values. + neg_items : list + List of negative values. + + Returns + ------- + str + The ADQL predicate for the numeric list. + """ + def build_positive(col, items): + # Separate simple numeric values from complex expressions (comparisons, ranges) + simple_numbers = [] + complex_parts = [] + for val in items: + if isinstance(val, bool): + simple_numbers.append(int(val)) + elif isinstance(val, (int, float)): + simple_numbers.append(val) + elif isinstance(val, str): + try: + simple_numbers.append(float(val)) + except ValueError: + complex_parts.append(self._parse_numeric_expr(col, val)) + else: + try: + simple_numbers.append(float(val)) + except (ValueError, TypeError): + raise InvalidQueryError(f"Unsupported numeric value type: {type(val)}") + + parts = [] + if simple_numbers: + vals = [str(v) for v in simple_numbers] + parts.append(f"{col} IN (" + ", ".join(vals) + ")") + parts.extend(complex_parts) + return parts + + def build_negative(col, v): + # For negatives, we can't use NOT IN or NOT (complex), so we build each as a separate predicate + # and AND them together + return self._format_scalar_predicate(col, f"!{v}", is_numeric=True) + + return self._build_list_predicate( + col, + pos_items, + neg_items, + build_positive, + build_negative, + ) + + def _build_string_list_predicate(self, col, pos_items, neg_items): + """ + Build predicate for multiple values passed into a string column with separated positives and negatives. + + Parameters + ---------- + col : str + The column name. + pos_items : list + List of positive values. + neg_items : list + List of negative values. + + Returns + ------- + str + The ADQL predicate for the string list. + """ + def build_positive(col, items): + # Separate simple strings (no wildcards) from those that require LIKE + simple_strings = [] + pattern_parts = [] + for v in items: + if isinstance(v, bool): + simple_strings.append(str(int(v))) + elif isinstance(v, str): + if ("*" in v) or ("%" in v): + patt = self._quote_adql_string(v.replace("*", "%")) + pattern_parts.append(f"{col} LIKE '{patt}'") + else: + simple_strings.append("'" + self._quote_adql_string(v) + "'") + else: + simple_strings.append(str(v)) + + parts = [] + if simple_strings: + parts.append(f"{col} IN (" + ", ".join(simple_strings) + ")") + parts.extend(pattern_parts) + return parts + + # Negative predicates → use helper + def build_negative(col, v): + # For negatives, we can't use NOT IN or NOT (LIKE), so we build each as a separate predicate + # and AND them together + return self._format_scalar_predicate(col, f"!{v}") + + return self._build_list_predicate( + col, + pos_items, + neg_items, + build_positive, + build_negative, + ) + + def _build_temporal_list_predicate(self, col, pos_items, neg_items): + """ + Build temporal list predicates using column datatype to choose CAST vs string comparison. + + Parameters + ---------- + col : str + The column name. + pos_items : list + List of positive values. + neg_items : list + List of negative values. + + Returns + ------- + str + The ADQL predicate for the temporal list. + """ + return self._build_list_predicate( + col, + pos_items, + neg_items, + pos_builder=lambda c, vals: [self._parse_temporal_expr(c, v) for v in vals], + neg_builder=lambda c, v: self._format_scalar_predicate(c, f"!{v}", is_temporal=True), + ) + + def _format_criteria_conditions(self, collection_obj, catalog, criteria): + """ + Turn a criteria dict into ADQL WHERE clause expressions, aware of column types. + + - Scalars: equality (strings quoted; booleans -> 0/1; numerics raw). + - Strings with wildcards '*' or '%': uses LIKE (converting '*' to '%'). + - Lists/Tuples: if any string contains wildcard, build OR of LIKEs; otherwise use IN (...). + - Numeric columns: support comparison strings ('<10', '>= 5') and ranges ('5..10', inclusive). + Empty lists yield a false predicate (1=0). + - Negation: a value prefixed with '!' is treated as a negated predicate. For list values, all negations are + AND'ed together and combined with the OR of positives: (neg1 AND neg2) AND (pos1 OR pos2 ...). + + Parameters + ---------- + criteria : dict + Mapping of column name -> scalar or list of scalars. + + Returns + ------- + list of str + ADQL predicate strings (without leading WHERE/AND), suitable for joining with ' AND '. + """ + column_meta = collection_obj.get_catalog_metadata(catalog).column_metadata + numeric_cols, temporal_cols = self._classify_columns(column_meta) + conditions = [] + for key, value in criteria.items(): + # Handle list-like values => IN or OR(LIKE ...) + if isinstance(value, (list, tuple)): + values = list(value) + if len(values) == 0: + conditions.append("1=0") + continue + # Separate negatives (prefixed with '!') and positives + neg_items = [] + pos_items = [] + for v in values: + if isinstance(v, str) and v.startswith("!"): + neg_items.append(v[1:].strip()) + else: + pos_items.append(v) + + if key in temporal_cols: + expr = self._build_temporal_list_predicate(key, pos_items, neg_items) + if expr: + conditions.append(expr) + elif key in numeric_cols: + expr = self._build_numeric_list_predicate(key, pos_items, neg_items) + if expr: + conditions.append(expr) + else: + expr = self._build_string_list_predicate(key, pos_items, neg_items) + if expr: + conditions.append(expr) + else: + conditions.append(self._format_scalar_predicate(key, value, key in numeric_cols, key in temporal_cols)) + return conditions + + def _make_data_url(self, row): + """Return the correct data URL for a given spectrum row.""" + dataset = row["DatasetName"] + if row["SpectrumType"] < 2: + return f"https://hla.stsci.edu/cgi-bin/getdata.cgi?config=ops&dataset={dataset}" + return f"https://hla.stsci.edu/cgi-bin/ecfproxy?file_id={dataset}.fits" Catalogs = CatalogsClass() diff --git a/astroquery/mast/tests/data/README.rst b/astroquery/mast/tests/data/README.rst index 5e0a21f6d4..84b82bc727 100644 --- a/astroquery/mast/tests/data/README.rst +++ b/astroquery/mast/tests/data/README.rst @@ -62,3 +62,66 @@ To generate `~astroquery.mast.tests.data.resolver.json`, use the following: ... {'name': objects, 'outputFormat': 'json', 'resolveAll': 'true'}) >>> with open('resolver.json', 'w') as file: ... json.dump(resp.json(), file, indent=4) # doctest: +SKIP + +To generate `~astroquery.mast.tests.data.tap_collections.json`, use the following: + +.. doctest-remote-data:: + + >>> import json + >>> from astroquery.mast import utils + ... + >>> resp = utils._simple_request('https://mast.stsci.edu/vo-tap/api/v0.1/openapi.json') + ... + >>> with open('tap_collections.json', 'w') as file: + ... json.dump(resp.json(), file, indent=4) # doctest: +SKIP + +To generate `~astroquery.mast.tests.data.tap_catalogs.vot`, use the following: + +.. doctest-remote-data:: + + >>> import pyvo + >>> from astropy.io.votable import writeto + ... + >>> tap_service = pyvo.dal.TAPService("https://mast.stsci.edu/vo-tap/api/v0.1/tic") + >>> query = 'SELECT table_name, description FROM tap_schema.tables' + >>> result = tap_service.run_sync(query) + >>> writeto(result.votable, 'tap_catalogs.vot') # doctest: +SKIP + +To generate `~astroquery.mast.tests.data.tap_columns.vot`, use the following: + +.. doctest-remote-data:: + + >>> import pyvo + >>> from astropy.io.votable import writeto + ... + >>> tap_service = pyvo.dal.TAPService("https://mast.stsci.edu/vo-tap/api/v0.1/tic") + >>> query = "SELECT column_name, datatype, unit, ucd, description FROM tap_schema.columns WHERE table_name = 'dbo.catalogrecord'" + >>> result = tap_service.run_sync(query) + >>> writeto(result.votable, 'tap_columns.vot') # doctest: +SKIP + +To generate `~astroquery.mast.tests.data.tap_capabilities.xml`, use the following: + +.. doctest-remote-data:: + + >>> import requests + >>> import pyvo + ... + >>> tap_service = pyvo.dal.TAPService("https://mast.stsci.edu/vo-tap/api/v0.1/tic") + >>> caps_url = tap_service.baseurl.rstrip("/") + "/capabilities" + >>> resp = requests.get(caps_url) + >>> resp.raise_for_status() + ... + >>> with open("tap_capabilities.xml", "wb") as f: + ... f.write(resp.content) # doctest: +SKIP + +To generate `~astroquery.mast.tests.data.tap_results.vot`, use the following: + +.. doctest-remote-data:: + + >>> import pyvo + >>> from astropy.io.votable import writeto + ... + >>> tap_service = pyvo.dal.TAPService("https://mast.stsci.edu/vo-tap/api/v0.1/tic") + >>> query = "SELECT TOP 10 * FROM dbo.catalogrecord WHERE CONTAINS(POINT('ICRS', ra, dec), CIRCLE('ICRS', 23.34086, 60.658, 0.002)) = 1" + >>> result = tap_service.run_sync(query) + >>> writeto(result.votable, 'tap_results.vot') # doctest: +SKIP diff --git a/astroquery/mast/tests/data/tap_capabilities.xml b/astroquery/mast/tests/data/tap_capabilities.xml new file mode 100644 index 0000000000..60911ad49c --- /dev/null +++ b/astroquery/mast/tests/data/tap_capabilities.xml @@ -0,0 +1 @@ +https://masttest.stsci.edu/vo-tap/api/v0.1/ticADQL2.0ADQL-2.0
CONTAINS
POINT
CIRCLE
application/jsonjsontext/csv;header=presentcsvapplication/xmlxmlapplication/x-votable+xmlvotable100000100000
https://masttest.stsci.edu/vo-tap/api/v0.1/tic/capabilitieshttps://masttest.stsci.edu/vo-tap/api/v0.1/tic/availabilityhttps://masttest.stsci.edu/vo-tap/api/v0.1/tic/tableshttps://masttest.stsci.edu/vo-tap/api/v0.1/tic/examples
\ No newline at end of file diff --git a/astroquery/mast/tests/data/tap_catalogs.vot b/astroquery/mast/tests/data/tap_catalogs.vot new file mode 100644 index 0000000000..21dbeffe59 --- /dev/null +++ b/astroquery/mast/tests/data/tap_catalogs.vot @@ -0,0 +1,49 @@ + + + + + + + + + + Fully qualified table name + + + + + Brief description of the table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
tap_schema.schemasdescription of schemas in this dataset
tap_schema.tablesdescription of tables in this dataset
tap_schema.columnsdescription of columns in this dataset
tap_schema.keysdescription of foreign keys in this dataset
tap_schema.key_columnsdescription of foreign key columns in this dataset
dbo.catalogrecordMain Catalog Record
+
+
diff --git a/astroquery/mast/tests/data/tap_collections.json b/astroquery/mast/tests/data/tap_collections.json new file mode 100644 index 0000000000..73509c6a97 --- /dev/null +++ b/astroquery/mast/tests/data/tap_collections.json @@ -0,0 +1,1960 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "tap_search", + "version": "0.1" + }, + "paths": { + "/{catalog}/": { + "get": { + "tags": [ + "sync", + "sync" + ], + "summary": "Taphandler.Get Site Page", + "description": "Returns the service query manager doc page", + "operationId": "TAPHandler_get_site_page__catalog___get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to view example queries for." + }, + "description": "Catalog to view example queries for." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/sync": { + "get": { + "tags": [ + "sync", + "sync" + ], + "summary": "Taphandler.Get", + "description": "VO TAP Search", + "operationId": "TAPHandler_get__catalog__sync_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "catalog to search" + }, + "description": "catalog to search" + }, + { + "name": "lang", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/LangName", + "description": "Query language to be used for this request" + }, + "description": "Query language to be used for this request" + }, + { + "name": "responseformat", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/EncodingEnum", + "description": "Content type of the response", + "default": "votable" + }, + "description": "Content type of the response" + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/EncodingEnum", + "description": "Content type of the response", + "default": "votable" + }, + "description": "Content type of the response" + }, + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Query to perform", + "title": "Query" + }, + "description": "Query to perform" + }, + { + "name": "maxrec", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Maximum number of records to be returned (max 100k)", + "default": 100000, + "title": "Maxrec" + }, + "description": "Maximum number of records to be returned (max 100k)" + }, + { + "name": "runid", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 64 + }, + { + "type": "null" + } + ], + "description": "RUNID for the request", + "title": "Runid" + }, + "description": "RUNID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "sync", + "sync" + ], + "summary": "Taphandler.Post", + "description": "VO TAP Search (FORM)", + "operationId": "TAPHandler_post__catalog__sync_post", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "catalog to search" + }, + "description": "catalog to search" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_TAPHandler_post__catalog__sync_post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/examples": { + "get": { + "tags": [ + "sync", + "sync" + ], + "summary": "Exampleshandler.Get", + "description": "Page displaying example TAP queries.\n\nThese example pages conform to the DALI-examples spec and are used to provide both human and machine-readable\nexample queries to the user.\n\nhttps://www.ivoa.net/documents/DALI/20170517/REC-DALI-1.1.html#tth_sEc2.3\n\nDuring automated testing, the queries on this page are stripped from the XHTML and used as test queries.\nThe XHTML element tags of the queries are specific to this testing/spec and should not be changed without\nconsulting the above specification, then also changing the necessary tests.", + "operationId": "ExamplesHandler_get__catalog__examples_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to view example queries for." + }, + "description": "Catalog to view example queries for." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/tables": { + "get": { + "tags": [ + "vosi", + "vosi" + ], + "summary": "Vositableshandler.Get All Tables", + "description": "Return information on all available tables.", + "operationId": "VOSITablesHandler_get_all_tables__catalog__tables_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to view VOSI table info for." + }, + "description": "Catalog to view VOSI table info for." + }, + { + "name": "detail", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Amount of table detail to return.", + "default": "min", + "title": "Detail" + }, + "description": "Amount of table detail to return." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/tables/{table}": { + "get": { + "tags": [ + "vosi", + "vosi" + ], + "summary": "Vositableshandler.Get Table", + "description": "Return information for a specific table.", + "operationId": "VOSITablesHandler_get_table__catalog__tables__table__get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to view VOSI table info for." + }, + "description": "Catalog to view VOSI table info for." + }, + { + "name": "table", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The specific table to view info for.", + "title": "Table" + }, + "description": "The specific table to view info for." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/availability": { + "get": { + "tags": [ + "vosi", + "vosi" + ], + "summary": "Vosiavailabilityhandler.Get Availability", + "description": "Availability metadata defined in the VOSI 1.1 spec at:\nhttps://www.ivoa.net/documents/VOSI/20170524/REC-VOSI-1.1.html#tth_sEc3.2", + "operationId": "VOSIAvailabilityHandler_get_availability__catalog__availability_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to view VOSI availability info for." + }, + "description": "Catalog to view VOSI availability info for." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/capabilities": { + "get": { + "tags": [ + "vosi", + "vosi" + ], + "summary": "Vosicapabilitieshandler.Get Capabilities", + "description": "TAP specific capability metadata as described in the service VOResource", + "operationId": "VOSICapabilitiesHandler_get_capabilities__catalog__capabilities_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Catalog to view VOSI capability info for.", + "title": "Catalog" + }, + "description": "Catalog to view VOSI capability info for." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Async", + "description": "Returns Jobs list per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_get_async__catalog__async_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to view example queries for." + }, + "description": "Catalog to view example queries for." + }, + { + "name": "phase", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExecutionPhase" + } + }, + { + "type": "null" + } + ], + "description": "The current execution phase to filter jobs by.", + "title": "Phase" + }, + "description": "The current execution phase to filter jobs by." + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Return jobs with creation times after the given date.", + "title": "After" + }, + "description": "Return jobs with creation times after the given date." + }, + { + "name": "last", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "description": "Return the given number of most recently created jobs.", + "title": "Last" + }, + "description": "Return the given number of most recently created jobs." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Post", + "description": "VO TAP Search, asynchronous job setup via form", + "operationId": "TAPAsyncHandler_post__catalog__async_post", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to query." + }, + "description": "Catalog to query." + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_TAPAsyncHandler_post__catalog__async_post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async/{job_id}/results/result": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Job Results", + "description": "Returns job results if existent, per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_get_job_results__catalog__async__job_id__results_result_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog for querying" + }, + "description": "Catalog for querying" + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async/{job_id}/results": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Job Info Detail Results", + "description": "Return job details per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_get_job_info_detail_results__catalog__async__job_id__results_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog for querying" + }, + "description": "Catalog for querying" + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async/{job_id}/error": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Job Info Detail Error", + "description": "Return job details per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding\nIn case of a valid job with no errors, an empty 200 OK response is returned.\n\nFor a TAP service, the response should match the requested format, if specified.\nhttps://www.ivoa.net/documents/TAP/20190927/REC-TAP-1.1.html#tth_sEc3.3", + "operationId": "TAPAsyncHandler_get_job_info_detail_error__catalog__async__job_id__error_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog for querying" + }, + "description": "Catalog for querying" + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async/{job_id}/phase": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Job Info Detail Phase", + "description": "Return job details per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_get_job_info_detail_phase__catalog__async__job_id__phase_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog for querying" + }, + "description": "Catalog for querying" + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Post Update Job Phase", + "description": "Job control for existing jobs via POST per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_post_update_job_phase__catalog__async__job_id__phase_post", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog associated with this job." + }, + "description": "Catalog associated with this job." + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_TAPAsyncHandler_post_update_job_phase__catalog__async__job_id__phase_post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async/{job_id}/executionduration": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Job Info Detail Executionduration", + "description": "Execution Duration is a potentially client-negotiated timeout value in seconds.\nIf it is not explicitly set by the client, a server default is used.\nReturn job details per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_get_job_info_detail_executionduration__catalog__async__job_id__executionduration_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog for querying" + }, + "description": "Catalog for querying" + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Post Update Job Execution Duration", + "description": "Updates job execution timeout, in seconds.\nOnly has an effect before the job is set to Phase RUN.\nNot supported on all catalogs. Limited by a server side max.\nJob control for existing jobs via POST per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_post_update_job_execution_duration__catalog__async__job_id__executionduration_post", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog associated with this job." + }, + "description": "Catalog associated with this job." + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_TAPAsyncHandler_post_update_job_execution_duration__catalog__async__job_id__executionduration_post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async/{job_id}/destruction": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Job Info Detail Destruction", + "description": "Return job details per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_get_job_info_detail_destruction__catalog__async__job_id__destruction_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog for querying" + }, + "description": "Catalog for querying" + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Post Update Job Destruction", + "description": "Job control for existing jobs via POST per UWS spec:\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#DestructionTime\nThe service may forbid changes, or may set limits on the allowed destruction time.\nDestruction datetime is expected in ISO 8601 UTC format with Z for UTC times:\nhttps://www.ivoa.net/documents/VOResource/20180625/REC-VOResource-1.1.html#tth_sEc2.2.4", + "operationId": "TAPAsyncHandler_post_update_job_destruction__catalog__async__job_id__destruction_post", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to view example queries for." + }, + "description": "Catalog to view example queries for." + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_TAPAsyncHandler_post_update_job_destruction__catalog__async__job_id__destruction_post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async/{job_id}/quote": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Job Info Detail Quote", + "description": "Job completion time estimate quote not provided by this service, returns empty value.\nReturn job details per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_get_job_info_detail_quote__catalog__async__job_id__quote_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog for querying" + }, + "description": "Catalog for querying" + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async/{job_id}/parameters": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Job Info Detail Parameters", + "description": "Return job details per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_get_job_info_detail_parameters__catalog__async__job_id__parameters_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog for querying" + }, + "description": "Catalog for querying" + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Update Job Info Parameters", + "description": "Update job parameters by submitting a POST of key-value pairs.", + "operationId": "TAPAsyncHandler_update_job_info_parameters__catalog__async__job_id__parameters_post", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog for querying" + }, + "description": "Catalog for querying" + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_TAPAsyncHandler_update_job_info_parameters__catalog__async__job_id__parameters_post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async/{job_id}/owner": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Job Info Detail Owner", + "description": "Return job details per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_get_job_info_detail_owner__catalog__async__job_id__owner_get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog for querying." + }, + "description": "Catalog for querying." + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/{catalog}/async/{job_id}": { + "get": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Get Job Info", + "description": "Returns specific Job info per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_get_job_info__catalog__async__job_id__get", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to view example queries for." + }, + "description": "Catalog to view example queries for." + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + }, + { + "name": "wait", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Maximum time in seconds to block until a job status change", + "title": "Wait" + }, + "description": "Maximum time in seconds to block until a job status change" + }, + { + "name": "phase", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExecutionPhase" + }, + { + "type": "null" + } + ], + "description": "For blocking behavior, can supply the current execution phase to monitor for changes", + "title": "Phase" + }, + "description": "For blocking behavior, can supply the current execution phase to monitor for changes" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Post Update Job Info", + "description": "Job control for existing jobs via POST per UWS spec\nhttps://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#RESTbinding", + "operationId": "TAPAsyncHandler_post_update_job_info__catalog__async__job_id__post", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to view example queries for." + }, + "description": "Catalog to view example queries for." + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_TAPAsyncHandler_post_update_job_info__catalog__async__job_id__post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "async", + "async" + ], + "summary": "Tapasynchandler.Delete Job Route", + "description": "Deletes specific Job info per UWS spec. Cancels if running. Note the /jobs path for TAP is just /async\nPer https://www.ivoa.net/documents/UWS/20161024/REC-UWS-1.1-20161024.html#d1e1390:\nSending a HTTP DELETE to a Job resource destroys that job, with the meaning noted in the definition\nof the Job object, above. No other resource of the UWS may be directly deleted by the client.\nThe response to this request must have code 303 \u201cSee other\u201d and the Location header of the response\nmust point to the Job List at the /{jobs} URI.", + "operationId": "TAPAsyncHandler_delete_job_route__catalog__async__job_id__delete", + "parameters": [ + { + "name": "catalog", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/CatalogName", + "description": "Catalog to view example queries for." + }, + "description": "Catalog to view example queries for." + }, + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64, + "description": "Server-assigned job ID for the request", + "title": "Job Id" + }, + "description": "Server-assigned job ID for the request" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Body_TAPAsyncHandler_post__catalog__async_post": { + "properties": { + "LANG": { + "anyOf": [ + { + "$ref": "#/components/schemas/LangName" + }, + { + "type": "string" + } + ], + "title": "Lang", + "description": "Query language to be used for this request", + "default": "ADQL" + }, + "responseformat": { + "$ref": "#/components/schemas/EncodingEnum", + "description": "Content type of the response", + "default": "votable" + }, + "FORMAT": { + "$ref": "#/components/schemas/EncodingEnum", + "description": "Content type of the response", + "default": "votable" + }, + "QUERY": { + "type": "string", + "title": "Query", + "description": "Query to perform", + "default": "" + }, + "MAXREC": { + "type": "integer", + "minimum": 0.0, + "title": "Maxrec", + "description": "Maximum number of records to be returned (max 100k)", + "default": 100000 + }, + "RUNID": { + "anyOf": [ + { + "type": "string", + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "Runid", + "description": "RUNID for the request", + "default": "" + }, + "PHASE": { + "anyOf": [ + { + "type": "string", + "enum": [ + "RUN" + ], + "const": "RUN" + }, + { + "type": "null" + } + ], + "title": "Phase", + "description": "Execution Phase (set to RUN for autorun)" + } + }, + "type": "object", + "title": "Body_TAPAsyncHandler_post__catalog__async_post" + }, + "Body_TAPAsyncHandler_post_update_job_destruction__catalog__async__job_id__destruction_post": { + "properties": { + "DESTRUCTION": { + "type": "string", + "format": "date-time", + "title": "Destruction", + "description": "ISO 8601 UTC datetime for proposed job destruction" + } + }, + "type": "object", + "title": "Body_TAPAsyncHandler_post_update_job_destruction__catalog__async__job_id__destruction_post" + }, + "Body_TAPAsyncHandler_post_update_job_execution_duration__catalog__async__job_id__executionduration_post": { + "properties": { + "EXECUTIONDURATION": { + "type": "integer", + "minimum": 0.0, + "title": "Executionduration", + "description": "Requested execution duration in seconds", + "default": 600 + } + }, + "type": "object", + "title": "Body_TAPAsyncHandler_post_update_job_execution_duration__catalog__async__job_id__executionduration_post" + }, + "Body_TAPAsyncHandler_post_update_job_info__catalog__async__job_id__post": { + "properties": { + "PHASE": { + "anyOf": [ + { + "$ref": "#/components/schemas/PhaseAction" + }, + { + "type": "null" + } + ], + "description": "Execution Phase" + }, + "DESTRUCTION": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Destruction", + "description": "ISO 8601 UTC datetime for proposed job destruction" + }, + "ACTION": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Action", + "description": "Action to apply to job: Only DELETE supported." + } + }, + "type": "object", + "title": "Body_TAPAsyncHandler_post_update_job_info__catalog__async__job_id__post" + }, + "Body_TAPAsyncHandler_post_update_job_phase__catalog__async__job_id__phase_post": { + "properties": { + "PHASE": { + "$ref": "#/components/schemas/PhaseAction", + "description": "Execution Phase" + } + }, + "type": "object", + "required": [ + "PHASE" + ], + "title": "Body_TAPAsyncHandler_post_update_job_phase__catalog__async__job_id__phase_post" + }, + "Body_TAPAsyncHandler_update_job_info_parameters__catalog__async__job_id__parameters_post": { + "properties": { + "QUERY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Query", + "description": "Query to be executed" + }, + "LANG": { + "anyOf": [ + { + "$ref": "#/components/schemas/LangName" + }, + { + "type": "null" + } + ], + "description": "Query langauge to be used." + }, + "RESPONSEFORMAT": { + "anyOf": [ + { + "$ref": "#/components/schemas/EncodingEnum" + }, + { + "type": "null" + } + ], + "description": "Content type of the response" + }, + "FORMAT": { + "anyOf": [ + { + "$ref": "#/components/schemas/EncodingEnum" + }, + { + "type": "null" + } + ], + "description": "Content type of the response" + }, + "MAXREC": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Maxrec", + "description": "Maximum number of records to return" + }, + "RUNID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Runid", + "description": "Run ID to be used" + } + }, + "type": "object", + "title": "Body_TAPAsyncHandler_update_job_info_parameters__catalog__async__job_id__parameters_post" + }, + "Body_TAPHandler_post__catalog__sync_post": { + "properties": { + "LANG": { + "$ref": "#/components/schemas/LangName", + "description": "Query language to be used for this request" + }, + "responseformat": { + "$ref": "#/components/schemas/EncodingEnum", + "description": "Content type of the response", + "default": "votable" + }, + "format": { + "$ref": "#/components/schemas/EncodingEnum", + "description": "Content type of the response", + "default": "votable" + }, + "QUERY": { + "type": "string", + "title": "Query", + "description": "Query to perform" + }, + "MAXREC": { + "type": "integer", + "minimum": 0.0, + "title": "Maxrec", + "description": "Maximum number of records to be returned (max 100k)", + "default": 100000 + }, + "runid": { + "anyOf": [ + { + "type": "string", + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "Runid", + "description": "RUNID for the request" + } + }, + "type": "object", + "required": [ + "LANG", + "QUERY" + ], + "title": "Body_TAPHandler_post__catalog__sync_post" + }, + "CatalogName": { + "type": "string", + "enum": [ + "caom", + "classy", + "gaiadr3", + "hsc", + "hscv2", + "mast_catalogs", + "missionmast", + "ps1dr1", + "ps1dr2", + "registry", + "roman_catalogs", + "skymapperdr4", + "tic", + "ullyses", + "goods", + "candels", + "3dhst", + "deepspace" + ], + "title": "CatalogName" + }, + "EncodingEnum": { + "type": "string", + "enum": [ + "json", + "csv", + "xml", + "votable" + ], + "title": "EncodingEnum", + "description": "Enumeration class for the format_param (format) query param" + }, + "ExecutionPhase": { + "type": "string", + "enum": [ + "PENDING", + "QUEUED", + "EXECUTING", + "COMPLETED", + "ERROR", + "UNKNOWN", + "HELD", + "SUSPENDED", + "ABORTED", + "ARCHIVED" + ], + "title": "ExecutionPhase", + "description": "Enumeration of possible phases of job execution." + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "LangName": { + "type": "string", + "enum": [ + "ADQL", + "ADQL-2.1" + ], + "title": "LangName", + "description": "Enum for language names as param" + }, + "PhaseAction": { + "type": "string", + "enum": [ + "ABORT", + "RUN" + ], + "title": "PhaseAction", + "description": "Enum for async job phase requests" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + }, + "servers": [ + { + "url": "/vo-tap/api/v0.1" + } + ] +} \ No newline at end of file diff --git a/astroquery/mast/tests/data/tap_columns.vot b/astroquery/mast/tests/data/tap_columns.vot new file mode 100644 index 0000000000..3a51181cdd --- /dev/null +++ b/astroquery/mast/tests/data/tap_columns.vot @@ -0,0 +1,915 @@ + + + + + + + + + + Column name + + + + + ADQL datatype + + + + + Unit in VO standard format + + + + + UCD of column if any + + + + + Brief description of column + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
idlong + meta.id;meta.maintess input catalog identifier
versionchar + meta.idcatalog version
hipint + meta.idhipparcos identifier
tycchar + meta.idtycho2 identifier
ucacchar + meta.iducac4 identifier
twomasschar + meta.id2mass identifier (hhmmssss+ddmmsss j2000)
sdsslong + meta.idsdss dr9 objid identifier
allwisechar + meta.idallwise identifier (jhhmmss.ss+ddmmss.s)
gaiachar + meta.idgaia dr2 identifier
apasschar + meta.idapass dr9 identifier
kicint + meta.idkic identifier
objtypechar + src.class.stargalaxyobject type (star or extended)
typesrcchar + meta.refthe source of the object in the tic
radoubledegpos.eq.ra;meta.mainright ascension (j2000)
decdoubledegpos.eq.dec;meta.maindeclination (j2000)
posflagchar + meta.ref;pos.framesource of the position
pmrafloatmas/yrpos.pm;pos.eq.raproper motion in right ascension
e_pmrafloatmas/yrstat.error;pos.pm;pos.eq.rauncertainty in pmra
pmdecfloatmas/yrpos.pm;pos.eq.decproper motion in declination
e_pmdecfloatmas/yrstat.error;pos.pm;pos.eq.decuncertainty in pmde
pmflagchar + meta.ref;pos.framesource of the proper motion
plxfloatmaspos.parallaxparallax
e_plxfloatmasstat.error;pos.parallaxerror in the parallax
parflagchar + meta.ref;pos.framesource of the parallax
gallongdoubledegpos.galactic.longalactic longitude
gallatdoubledegpos.galactic.latgalactic latitude
eclongdoubledegpos.ecliptic.lonecliptic longitude
eclatdoubledegpos.ecliptic.latecliptic latitude
bmagfloatmagphot.mag;em.opt.bjohnson b magnitude
e_bmagfloatmagstat.error;phot.maguncertainty in bmag
vmagfloatmagphot.mag;em.opt.vjohnson v magnitude
e_vmagfloatmagstat.error;phot.maguncertainty in vmag
umagfloatmagphot.mag;em.opt.usdss u-band (ab) magnitude
e_umagfloatmagstat.error;phot.maguncertainty in umag
gmagfloatmagphot.mag;em.opt.bsdss g-band (ab) magnitude
e_gmagfloatmagstat.error;phot.maguncertainty in gmag
rmagfloatmagphot.mag;em.opt.rsdss r-band (ab) magnitude
e_rmagfloatmagstat.error;phot.maguncertainty in rmag
imagfloatmagphot.mag;em.opt.isdss i-band (ab) magnitude
e_imagfloatmagstat.error;phot.maguncertainty in imag
zmagfloatmagphot.mag;em.opt.isdss z-band (ab) magnitude
e_zmagfloatmagstat.error;phot.maguncertainty in zmag
jmagfloatmagphot.mag;em.ir.j2mass johnson j-band magnitude
e_jmagfloatmagstat.error;phot.maguncertainty in jmag
hmagfloatmagphot.mag;em.ir.h2mass johnson h-band magnitude
e_hmagfloatmagstat.error;phot.maguncertainty in hmag
kmagfloatmagphot.mag;em.ir.k2mass johnson k-band magnitude
e_kmagfloatmagstat.error;phot.maguncertainty in kmag
twomflagchar + meta.code.qualquality flags for 2mass
proxfloatarcsecpos.angdistanceobject proximity
w1magfloatmagphot.mag;em.ir.3-4umallwise w1 (3.4um) magnitude
e_w1magfloatmagstat.error;phot.maguncertainty in w1mag
w2magfloatmagphot.mag;em.ir.4-8umallwise w2 (4.6um) magnitude
e_w2magfloatmagstat.error;phot.maguncertainty in w2mag
w3magfloatmagphot.mag;em.ir.8-15umallwise w3 (12um) magnitude
e_w3magfloatmagstat.error;phot.maguncertainty in w3mag
w4magfloatmagphot.mag;em.ir.15-30umallwise w4 (22um) magnitude
e_w4magfloatmagstat.error;phot.maguncertainty in w4mag
gaiamagfloatmagphot.mag;em.opt.vgaiadr2 g magnitude
e_gaiamagfloatmagstat.error;phot.maguncertainty in gmag
tmagfloatmagphot.mag;em.opttess magnitude
e_tmagfloatmagstat.error;phot.maguncertainty in tess mag
tessflagchar + meta.codetess magnitude flag
spflagchar + meta.codestellar properties flag
tefffloatKphys.temperature.effectiveeffective temperature
e_tefffloatKstat.error;phys.temperature.effectiveuncertainty in teff
loggfloatcm/s**2phys.gravitylog of the surface gravity
e_loggfloatcm/s**2stat.error;phys.gravityuncertainty in logg
mhfloat + phys.abund.zmetallicity [m/h]
e_mhfloat + stat.error;phys.abund.zuncertainty in [m/h]
radfloatsolRadphys.size.radiusradius
e_radfloatsolRadstat.error;phys.size.radiusuncertainty in radius
massfloatsolMassphys.massmass
e_massfloatsolMassstat.error;phys.massuncertainty in mass
rhofloat + phys.densitystellar density in solar units (mass/rad^3)
e_rhofloat + stat.error;phys.densityuncertainty in rho
lumclasschar + src.class.luminosityluminosity class
lumfloatsolLumphys.luminositystellar luminosity; rad^2*(teff/5772)^4
e_lumfloatsolLumstat.error;phys.luminosityuncertainty in luminosity
dfloatpcpos.distance;pos.heliocentricdistance
e_dfloatpcstat.error;pos.distanceuncertainty in distance
ebvfloatmagphot.color.excessapplied color excess
e_ebvfloatmagstat.error;phot.color.excessuncertainty in e(b-v)
numcontint + meta.numbernumber of contaminants found within 10arcsec of the star used in the calculation of the contamination ratio
contratiofloat + stat.fitcontamination ratio
dispositionchar + meta.code.classdisposition type
duplicate_idlong + meta.code.multiptic id of another object in duplicate or split set of stars
priorityfloat + meta.numberpriority (number from 0 to 1 = highest priority)
eneg_ebvfloatmagstat.error;phot.color.excess;stat.minnegative error for e(b-v)
epos_ebvfloatmagstat.error;phot.color.excess;stat.maxpositive error for e(b-v)
ebvflagchar + meta.ref;phot.color.excesssource of e(b-v)
eneg_massfloatsolMassstat.error;phys.mass;stat.minnegative error for mass
epos_massfloatsolMassstat.error;phys.mass;stat.maxpositive error for mass
eneg_radfloatsolRadstat.error;phys.size.radius;stat.minnegative error for radius
epos_radfloatsolRadstat.error;phys.size.radius;stat.maxpositive error for radius
eneg_rhofloat + stat.error;phys.density;stat.minnegative error for stellar density
epos_rhofloat + stat.error;phys.density;stat.maxpositive error for stellar density
eneg_loggfloatcm/s**2stat.error;phys.gravity;stat.minnegative error for surface gravity
epos_loggfloatcm/s**2stat.error;phys.gravity;stat.maxpositive error for surface gravity
eneg_lumfloatsolLumstat.error;phys.luminosity;stat.minnegative error for luminosity
epos_lumfloatsolLumstat.error;phys.luminosity;stat.maxpositive error for luminosity
eneg_distfloatpcstat.error;pos.distance;stat.minnegative error for distance
epos_distfloatpcstat.error;pos.distance;stat.maxpositive error for distance
distflagchar + meta.ref;pos.distancesource for distance
eneg_tefffloatKstat.error;phys.temperature.effective;stat.minnegative error for effective temperature
epos_tefffloatKstat.error;phys.temperature.effective;stat.maxpositive error for effective temperature
teffflagchar + meta.ref;phys.temperature.effectivesource for effective temperature
gaiabpfloatmagphot.mag;em.opt.bgaiadr2 bp magnitude
e_gaiabpfloatmagstat.error;phot.maguncertainty in bp magnitude
gaiarpfloatmagphot.mag;em.opt.rgaiadr2 rp magnitude
e_gaiarpfloatmagstat.error;phot.maguncertainty in rp magnitude
gaiaqflagint + meta.code.qualquality flags for gaia information
starchareflagchar + stat.errorasymmetric errors
vmagflagchar + meta.ref;phot.mag;em.opt.vsource of v magnitude
bmagflagchar + meta.ref;phot.mag;em.opt.bsource of b magnitude
splistschar + meta.codeidentifies if star is in a specially curated list
e_radoublemasstat.error;pos.eq.raerror in radeg
e_decdoublemasstat.error;pos.eq.decerror in decgeg
ra_origdoubledegpos.eq.rara from original catalog
dec_origdoubledegpos.eq.decdec from original catalog
e_ra_origdoublemasstat.error;pos.eq.raerror in ra_orig
e_dec_origdoublemasstat.error;pos.eq.decerror in dec_orig
raddflagint + meta.codedwarf by radius; 0: giant by radius; -1: insufficient information
wdflagint + meta.codestar in gaia photometric white dwarf region
objidlong + meta.recorddatabase internal identifier
+
+
diff --git a/astroquery/mast/tests/data/tap_results.vot b/astroquery/mast/tests/data/tap_results.vot new file mode 100644 index 0000000000..9900f6c70f --- /dev/null +++ b/astroquery/mast/tests/data/tap_results.vot @@ -0,0 +1,895 @@ + + + + + + + + + + tess input catalog identifier + + + + + catalog version + + + + + hipparcos identifier + + + + + tycho2 identifier + + + + + ucac4 identifier + + + + + 2mass identifier (hhmmssss+ddmmsss j2000) + + + + + sdss dr9 objid identifier + + + + + allwise identifier (jhhmmss.ss+ddmmss.s) + + + + + gaia dr2 identifier + + + + + apass dr9 identifier + + + + + kic identifier + + + + + object type (star or extended) + + + + + the source of the object in the tic + + + + + right ascension (j2000) + + + + + declination (j2000) + + + + + source of the position + + + + + proper motion in right ascension + + + + + uncertainty in pmra + + + + + proper motion in declination + + + + + uncertainty in pmde + + + + + source of the proper motion + + + + + parallax + + + + + error in the parallax + + + + + source of the parallax + + + + + galactic longitude + + + + + galactic latitude + + + + + ecliptic longitude + + + + + ecliptic latitude + + + + + johnson b magnitude + + + + + uncertainty in bmag + + + + + johnson v magnitude + + + + + uncertainty in vmag + + + + + sdss u-band (ab) magnitude + + + + + uncertainty in umag + + + + + sdss g-band (ab) magnitude + + + + + uncertainty in gmag + + + + + sdss r-band (ab) magnitude + + + + + uncertainty in rmag + + + + + sdss i-band (ab) magnitude + + + + + uncertainty in imag + + + + + sdss z-band (ab) magnitude + + + + + uncertainty in zmag + + + + + 2mass johnson j-band magnitude + + + + + uncertainty in jmag + + + + + 2mass johnson h-band magnitude + + + + + uncertainty in hmag + + + + + 2mass johnson k-band magnitude + + + + + uncertainty in kmag + + + + + quality flags for 2mass + + + + + object proximity + + + + + allwise w1 (3.4um) magnitude + + + + + uncertainty in w1mag + + + + + allwise w2 (4.6um) magnitude + + + + + uncertainty in w2mag + + + + + allwise w3 (12um) magnitude + + + + + uncertainty in w3mag + + + + + allwise w4 (22um) magnitude + + + + + uncertainty in w4mag + + + + + gaiadr2 g magnitude + + + + + uncertainty in gmag + + + + + tess magnitude + + + + + uncertainty in tess mag + + + + + tess magnitude flag + + + + + stellar properties flag + + + + + effective temperature + + + + + uncertainty in teff + + + + + log of the surface gravity + + + + + uncertainty in logg + + + + + metallicity [m/h] + + + + + uncertainty in [m/h] + + + + + radius + + + + + uncertainty in radius + + + + + mass + + + + + uncertainty in mass + + + + + stellar density in solar units (mass/rad^3) + + + + + uncertainty in rho + + + + + luminosity class + + + + + stellar luminosity; rad^2*(teff/5772)^4 + + + + + uncertainty in luminosity + + + + + distance + + + + + uncertainty in distance + + + + + applied color excess + + + + + uncertainty in e(b-v) + + + + + number of contaminants found within 10arcsec of the star used in + the calculation of the contamination ratio + + + + + contamination ratio + + + + + disposition type + + + + + tic id of another object in duplicate or split set of stars + + + + + priority (number from 0 to 1 = highest priority) + + + + + negative error for e(b-v) + + + + + positive error for e(b-v) + + + + + source of e(b-v) + + + + + negative error for mass + + + + + positive error for mass + + + + + negative error for radius + + + + + positive error for radius + + + + + negative error for stellar density + + + + + positive error for stellar density + + + + + negative error for surface gravity + + + + + positive error for surface gravity + + + + + negative error for luminosity + + + + + positive error for luminosity + + + + + negative error for distance + + + + + positive error for distance + + + + + source for distance + + + + + negative error for effective temperature + + + + + positive error for effective temperature + + + + + source for effective temperature + + + + + gaiadr2 bp magnitude + + + + + uncertainty in bp magnitude + + + + + gaiadr2 rp magnitude + + + + + uncertainty in rp magnitude + + + + + quality flags for gaia information + + + + + asymmetric errors + + + + + source of v magnitude + + + + + source of b magnitude + + + + + identifies if star is in a specially curated list + + + + + error in radeg + + + + + error in decgeg + + + + + ra from original catalog + + + + + dec from original catalog + + + + + error in ra_orig + + + + + error in dec_orig + + + + + dwarf by radius; 0: giant by radius; -1: insufficient information + + + + + star in gaia photometric white dwarf region + + + + + database internal identifier + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
42770553920190415 + + 382-12368718475108-1342233 + J184751.04-134222.641051754443095892486823646 + STARgaia2281.9629072438-13.7065985389427gaia2-5.661380.0764805-1.146080.0688171gaia20.3980230.0440811gaia220.2937183862185-5.42345791126297281.7724783304729.2451314111868416.8560.18115.3230.149 + + + + + + + + + + 11.9420.04711.2480.06810.7520.035EEE-222-111-000-0-0 + 10.3340.02310.4480.02110.6720.0889.134 + 14.71930.00047613.67730.0106reredgaia24418123 + + + + + + + + + + + + + 2360.4257.6050.5037430.0162428 + + SPLIT + + 0.01257280.0199128panstarrs + + + + + + + + + + 230.46284.75bj2018 + + dered15.77360.00978613.67780.0077211 + ucac4bpbj + 1.194495727858711.06728505482206281.962882153899-13.70660347345510.03513726893589260.03637388286974680022205941
60699594820190415 + + + + + + 510528438770827136 + + STARgaia219.415533913500161.0286394857995gaia2-0.9694640.1876690.8473730.18527gaia20.3482330.134513gaia2126.103557179616-1.6815130899793647.338495783371947.6142191319478 + + 18.58020.0486 + + + + + + + + + + + + + + + + + + + + + + + + + + 18.16120.00123517.38710.0271reredgaia25722154 + + + + + + + + + + + + + 2527.82921.4750.5078120.03941955 + + DUPLICATE54375580 + 0.02864960.0501895panstarrs + + + + + + + + + + 665.591177.36bj2018 + + dered18.75960.02656117.25690.0143231 + gaia2 + + 3.378239895504092.8735447568218719.415525295995661.02864313421130.1081048093514390.1033669687738271021716702
+
+
diff --git a/astroquery/mast/tests/test_mast.py b/astroquery/mast/tests/test_mast.py index 5eb868da2b..be7083dba6 100644 --- a/astroquery/mast/tests/test_mast.py +++ b/astroquery/mast/tests/test_mast.py @@ -4,25 +4,52 @@ import os import re import warnings +from datetime import datetime +from pathlib import Path from shutil import copyfile from unittest.mock import MagicMock, patch -from pathlib import Path import astropy.units as u -import pytest import numpy as np -from astropy.table import Table, unique -from astropy.coordinates import SkyCoord +import pytest +from astropy.coordinates import Angle, SkyCoord from astropy.io import fits +from astropy.io.votable import from_table, parse +from astropy.table import Table, unique +from astropy.time import Time from astropy.utils.exceptions import AstropyDeprecationWarning +from pyvo.dal import TAPResults +from pyvo.dal.exceptions import DALQueryError, DALServiceError +from pyvo.io.vosi import parse_capabilities from requests import HTTPError, Response -from astroquery.mast import (Catalogs, MastMissions, Observations, Tesscut, Zcut, Mast, utils, services, - discovery_portal, auth, core, cloud) +from astroquery.exceptions import ( + BlankResponseWarning, + InputWarning, + InvalidQueryError, + MaxResultsWarning, + NoResultsWarning, + RemoteServiceError, + ResolverError, +) +from astroquery.mast import ( + Catalogs, + Mast, + MastMissions, + Observations, + Tesscut, + Zcut, + auth, + cloud, + core, + discovery_portal, + services, + utils, +) from astroquery.mast.cloud import CloudAccess from astroquery.utils.mocks import MockResponse -from astroquery.exceptions import (BlankResponseWarning, InvalidQueryError, InputWarning, MaxResultsWarning, - NoResultsWarning, RemoteServiceError, ResolverError) + +from ..catalog_collection import DEFAULT_CATALOGS, CatalogCollection, CatalogMetadata try: # Optional dependency import for cloud access functionality @@ -30,6 +57,13 @@ except ImportError: pass +try: + # Optional dependency import for region handling in collections queries + from regions import CirclePixelRegion, CircleSkyRegion, PixCoord, PolygonSkyRegion + HAS_REGIONS = True +except ImportError: + HAS_REGIONS = False + DATA_FILES = {'Mast.Caom.Cone': 'caom.json', 'Mast.Name.Lookup': 'resolver.json', 'mission_search_results': 'mission_results.json', @@ -61,6 +95,11 @@ 'get_cloud_paths': 'mast_relative_path.json', 'panstarrs': 'panstarrs.json', 'panstarrs_columns': 'panstarrs_columns.json', + 'tap_collections': 'tap_collections.json', # Collections available + 'tap_catalogs': 'tap_catalogs.vot', # Catalogs for TIC + 'tap_columns': 'tap_columns.vot', # Column metadata + 'tap_capabilities': 'tap_capabilities.xml', # TAP service capabilities + 'tap_results': 'tap_results.vot', # Results of a TAP query 'tess_cutout': 'astrocut_107.27_-70.0_5x5.zip', 'tess_sector': 'tess_sector.json', 'z_cutout_fit': 'astrocut_189.49206_62.20615_100x100px_f.zip', @@ -81,6 +120,10 @@ def patch_post(request): mp.setattr(discovery_portal.PortalAPI, '_request', post_mockreturn) mp.setattr(services.ServiceAPI, '_request', service_mockreturn) mp.setattr(auth.MastAuth, 'session_info', session_info_mockreturn) + mp.setattr( + 'astroquery.mast.catalog_collection.TAPService', + lambda *args, **kwargs: vo_tap_mock() + ) mp.setattr(Tesscut, '_download_file', tesscut_download_mockreturn) mp.setattr(Zcut, '_download_file', zcut_download_mockreturn) @@ -89,6 +132,29 @@ def patch_post(request): return mp +@pytest.fixture +def patch_tap(request, reset_catalogs_cache): + """Fixture to patch the TAPService used in Catalogs queries.""" + mp = request.getfixturevalue("monkeypatch") + + mock_tap = vo_tap_mock() + mp.setattr( + 'astroquery.mast.catalog_collection.TAPService', + lambda *args, **kwargs: mock_tap + ) + # We have to set this because CatalogsClass uses a simple request to get collections + mp.setattr(utils, '_simple_request', request_mockreturn) + + return mock_tap + + +@pytest.fixture +def reset_catalogs_cache(): + """Fixture to clear the collections cache before each test to ensure test isolation.""" + Catalogs._collections_cache.clear() + yield + + @pytest.fixture() def patch_boto3(monkeypatch, reset_cloud_state): """Fixture to patch boto3 client and resource for cloud access tests.""" @@ -178,6 +244,8 @@ def request_mockreturn(url, params={}): filename = data_path(DATA_FILES['panstarrs_columns']) elif 'path_lookup' in url: filename = data_path(DATA_FILES['get_cloud_paths']) + elif 'vo-tap' in url: + filename = data_path(DATA_FILES['tap_collections']) with open(filename, 'rb') as infile: content = infile.read() return MockResponse(content) @@ -236,12 +304,70 @@ def zcut_download_mockreturn(url, file_path): return +def vo_tap_mock(): + """Helper function to create a mock TAPService with predefined responses based on the input query.""" + def run_sync_mock(query, **kwargs): + """Mock function to simulate TAPService run_sync and run_async methods.""" + if 'invalid' in query: + # Use this when wanting to simulate a DALQueryError + # Where it occurs will depend on where you pass it (collection, catalog, parameter, etc.) + raise DALQueryError('Simulated TAP query error for testing.') + elif 'timeout' in query: + # Use this when wanting to simulate a timeout error + raise DALServiceError('Simulated TAP service timeout for testing.', code=504) + elif 'COUNT' in query: + # Simulate a query that returns a count + votable = from_table(Table({'count_all': [42]})) # Example count value, can be adjusted as needed for tests + return TAPResults(votable) + elif 'tap_schema.tables' in query: + # Queries to get catalogs + filename = data_path(DATA_FILES['tap_catalogs']) + elif 'tap_schema.columns' in query: + # Queries to get column metadata + filename = data_path(DATA_FILES['tap_columns']) + elif 'WHERE' in query: + # Queries with results, keep in mind this is not meaningful and results won't match the query + filename = data_path(DATA_FILES['tap_results']) + votable = parse(filename) + + if 'empty' in query: + # Simulate a query that returns no results by clearing the resources in the votable + for resource in votable.resources: + for table in resource.tables: + table.array = np.array([]) # Clear the data to simulate no results + + return TAPResults(votable) + + # Mock TAPService + mock_tap = MagicMock() + mock_tap.run_sync.side_effect = run_sync_mock + mock_tap.run_async.side_effect = run_sync_mock + + # Capabilities parsing + filename = data_path(DATA_FILES['tap_capabilities']) + with open(filename, "rb") as f: + caps = parse_capabilities(f) + mock_tap.capabilities = caps + + return mock_tap + + +def get_patch_tap_query(patch_tap, run_async=False): + """Helper function to extract the ADQL query string from the mock TAP service calls.""" + if run_async: + args, _ = patch_tap.run_async.call_args + else: + args, _ = patch_tap.run_sync.call_args + query = args[0] + return query + + ########################### # MissionSearchClass Test # ########################### -def test_missions_query_region_async(): +def test_missions_query_region_async(patch_post): responses = MastMissions.query_region_async(regionCoords, radius=0.002, sci_pi_last_name='GORDON') assert isinstance(responses, MockResponse) @@ -1372,171 +1498,1069 @@ def test_observations_disable_cloud_dataset(patch_boto3): assert Observations._cloud_enabled_explicitly is False -###################### -# CatalogClass tests # -###################### +####################### +# CatalogsClass tests # +####################### -def test_catalogs_query_region_async(): - responses = Catalogs.query_region_async(regionCoords, radius=0.002) - assert isinstance(responses, list) +def test_catalogs_attributes(patch_tap): + Catalogs.query_criteria( + collection="tic", + region="Circle ICRS 202.4656816 +47.1999842 0.04", + radius=0.002 * u.deg, + offset=1, + sort_by="ra", + ) + # Should not change after query + assert Catalogs.collection == "hsc" + assert Catalogs.catalog == "dbo.SumMagAper2CatView" -def test_catalogs_fabric_query_region_async(): - responses = Catalogs.query_region_async(regionCoords, radius=0.002, catalog="panstarrs", table="mean") - assert isinstance(responses, MockResponse) +def test_catalogs_get_catalogs(patch_tap): + catalogs = Catalogs.get_catalogs("tic") + assert isinstance(catalogs, Table) + assert not any(catalog.startswith("tap_schema") for catalog in catalogs["catalog_name"]) -def test_catalogs_query_region(): - result = Catalogs.query_region(regionCoords, radius=0.002 * u.deg) - assert isinstance(result, Table) +def test_catalogs_get_column_metadata(patch_tap): + metadata = Catalogs.get_column_metadata(collection="tic") + assert isinstance(metadata, Table) - result = Catalogs.query_region(regionCoords, radius=0.002 * u.deg, catalog="hsc", version=2) - assert isinstance(result, Table) - with pytest.warns(InputWarning) as i_w: - Catalogs.query_region(regionCoords, radius=0.002 * u.deg, catalog="hsc", version=5) - assert "Invalid HSC version number" in str(i_w[0].message) +def test_catalogs_query_criteria(patch_tap): + # Coordinates + result = Catalogs.query_criteria( + collection="tic", + coordinates=regionCoords, + radius=0.002 * u.deg, + limit=2, + ) - result = Catalogs.query_region(regionCoords, radius=0.002 * u.deg, catalog="galex") assert isinstance(result, Table) + assert len(result) > 0 + assert "dec" in result.colnames + query = get_patch_tap_query(patch_tap) + assert "FROM dbo.catalogrecord" in query + assert "CONTAINS" in query + assert "CIRCLE" in query + assert "TOP 2" in query + # ADQL should be saved in result meta + assert "adql_query" in result.meta + assert result.meta["adql_query"] == query + + # Region query + result = Catalogs.query_criteria( + collection="tic", + region="Circle ICRS 202.4656816 +47.1999842 0.04", + radius=0.002 * u.deg, + offset=1, + sort_by="ra", + ) - result = Catalogs.query_region(regionCoords, radius=0.002 * u.deg, catalog="gaia", version=2) assert isinstance(result, Table) - - result = Catalogs.query_region(regionCoords, radius=0.002 * u.deg, catalog="gaia", version=1) + assert len(result) > 0 + assert "dec" in result.colnames + query = get_patch_tap_query(patch_tap) + assert "FROM dbo.catalogrecord" in query + assert "CONTAINS" in query + assert "CIRCLE" in query + assert "OFFSET" in query + assert "ORDER BY" in query + + # Non-positional query + result = Catalogs.query_criteria( + collection="tic", + filters={"pmra": [">-10", "<10"]}, + ) assert isinstance(result, Table) + assert len(result) > 0 + assert "dec" in result.colnames + query = get_patch_tap_query(patch_tap) + assert "FROM dbo.catalogrecord" in query + assert "WHERE" in query + assert "pmra > -10" in query + assert "pmra < 10" in query + + # Run with async + result = Catalogs.query_criteria( + collection="tic", + coordinates=regionCoords, + radius=0.002 * u.deg, + run_async=True + ) + assert isinstance(result, Table) + assert len(result) > 0 + query = get_patch_tap_query(patch_tap, run_async=True) + assert "FROM dbo.catalogrecord" in query - with pytest.warns(InputWarning) as i_w: - Catalogs.query_region(regionCoords, radius=0.002 * u.deg, catalog="gaia", version=5) - assert "Invalid Gaia version number" in str(i_w[0].message) + # Count only query + result = Catalogs.query_criteria( + collection="tic", + coordinates=regionCoords, + radius=0.002 * u.deg, + count_only=True + ) + assert isinstance(result, int) + query = get_patch_tap_query(patch_tap) + assert "COUNT(*)" in query - result = Catalogs.query_region(regionCoords, radius=0.002 * u.deg, catalog="Sample") - assert isinstance(result, Table) + # Only return ADQL + result = Catalogs.query_criteria( + collection="tic", + coordinates=regionCoords, + radius=0.002 * u.deg, + return_adql=True + ) + assert isinstance(result, str) + assert "FROM dbo.catalogrecord" in result -def test_catalogs_fabric_query_region(): - result = Catalogs.query_region(regionCoords, radius=0.002 * u.deg, catalog="panstarrs", table="mean") - assert isinstance(result, Table) +def test_catalogs_invalid_query_criteria(patch_tap): + # Specifying both region and coordinates + with pytest.raises(InvalidQueryError, match="Specify either `region` or `coordinates`"): + Catalogs.query_criteria( + collection="tic", + coordinates=regionCoords, + region="Circle ICRS 202.4656816 +47.1999842 0.04" + ) + + # Specifying both region and object_name + with pytest.raises(InvalidQueryError, match="Specify either `region` or `object_name`"): + Catalogs.query_criteria( + collection="tic", + object_name="M31", + region="Circle ICRS 202.4656816 +47.1999842 0.04" + ) + # Named parameters and filters dict specifying criteria + with pytest.raises(InvalidQueryError, match="Criteria specified both"): + Catalogs.query_criteria( + collection="tic", + object_name="M31", + file_suffix=['A', 'B', '!C'], + filters={"file_suffix": ['A', 'B', '!C']} + ) -def test_catalogs_query_object_async(): - responses = Catalogs.query_object_async("M101", radius="0.002 deg") - assert isinstance(responses, list) + # sort_by cols and sort_desc different lengths + with pytest.raises(InvalidQueryError, match="must be 1 or equal to length of 'sort_by'"): + Catalogs.query_criteria( + collection="tic", + coordinates=regionCoords, + sort_by=["ra", "dec"], + sort_desc=[True, False, True] + ) + # Invalid sort col + with pytest.raises(InvalidQueryError, match="Filter 'fake' is not recognized"): + Catalogs.query_criteria( + collection="tic", + coordinates=regionCoords, + sort_by="fake", + ) -def test_catalogs_fabric_query_object_async(): - responses = Catalogs.query_object_async("M101", radius="0.002 deg", catalog="panstarrs", table="mean") - assert isinstance(responses, MockResponse) + # Invalid filter + with pytest.raises(InvalidQueryError, match="Filter 'fake' is not recognized"): + Catalogs.query_criteria( + collection="tic", + fake=1 + ) + + # Collection is not a string + with pytest.raises(InvalidQueryError, match="Collection name must be a string."): + Catalogs.query_criteria( + collection=123, + objtype="star" + ) + # Warn if result table is empty + with pytest.warns(NoResultsWarning, match="The query returned no results."): + Catalogs.query_criteria( + collection="tic", + objtype="empty" + ) + + +def test_catalogs_query_region(patch_tap): + # Passing region coords and radius + result = Catalogs.query_region( + regionCoords, + radius=0.002 * u.deg, + collection="tic" + ) -def test_catalogs_query_object(): - result = Catalogs.query_object("M101", radius=".002 deg") assert isinstance(result, Table) + assert len(result) > 0 + assert "ra" in result.colnames + query = get_patch_tap_query(patch_tap) + assert "FROM dbo.catalogrecord" in query + assert "CONTAINS" in query + assert "CIRCLE" in query + assert "POINT" in query + + +def test_catalogs_invalid_query_region(): + # Query without region or coordinates + with pytest.raises(InvalidQueryError, match="Must specify either `region` or `coordinates`"): + Catalogs.query_region( + collection="tic", + ) + # Query with unsupported region type + with pytest.raises(InvalidQueryError, match="does not support ADQL region type 'POLYGON'"): + Catalogs.query_region( + region="Polygon ICRS 202.4656816 +47.1999842 202.5656816 +47.2999842 202.3656816 +47.0999842", + collection="tic" + ) + + +def test_catalogs_query_object(patch_tap): + # Object and radius query + radius = .001 + result = Catalogs.query_object( + "M10", + radius=radius, + collection="TIC" + ) -def test_catalogs_fabric_query_object(): - result = Catalogs.query_object("M101", radius=".002 deg", catalog="panstarrs", table="mean") assert isinstance(result, Table) + assert len(result) > 0 + assert "ra" in result.colnames + query = get_patch_tap_query(patch_tap) + assert "FROM dbo.catalogrecord" in query + assert "CONTAINS" in query + assert "POINT" in query + assert str(radius) in query + + +def test_catalogs_init_with_catalog(patch_tap): + catalog = Catalogs( + collection="tic", + catalog="tap_schema.schemas" + ) + assert catalog.catalog == "tap_schema.schemas" -def test_catalogs_query_criteria_async(): - responses = Catalogs.query_criteria_async(catalog="Tic", - Bmag=[30, 50], objType="STAR") - assert isinstance(responses, list) +def test_catalogs_setting_catalog(patch_tap): + catalog = Catalogs( + collection="tic", + catalog="tap_schema.schemas" + ) + catalog.catalog = "tap_schema.key_columns" + assert catalog.catalog == "tap_schema.key_columns" - responses = Catalogs.query_criteria_async(catalog="Ctl", - Bmag=[30, 50], objType="STAR") - assert isinstance(responses, list) - responses = Catalogs.query_criteria_async(catalog="Tic", object_name="M10", - Bmag=[30, 50], objType="STAR") - assert isinstance(responses, list) +def test_catalogs_get_collections_cached(patch_tap): + catalog = Catalogs("tic") + collections = catalog.get_collections() - responses = Catalogs.query_criteria_async(catalog="DiskDetective", - object_name="M10", radius=2, - state="complete") - assert isinstance(responses, list) + assert isinstance(collections, Table) + assert len(collections) > 0 + assert "collection_name" in collections.colnames - responses = Catalogs.query_criteria_async(catalog="panstarrs", object_name="M10", radius=2, - table="mean", qualityFlag=48) - assert isinstance(responses, MockResponse) - with pytest.raises(InvalidQueryError) as invalid_query: - Catalogs.query_criteria_async(catalog="Tic") - assert "non-positional" in str(invalid_query.value) +def test_catalogs_collection_cache_is_shared(patch_tap): + collection = "gaiadr3" + catalog_one = Catalogs(collection) + catalog_two = Catalogs(collection) - with pytest.raises(InvalidQueryError) as invalid_query: - Catalogs.query_criteria_async(catalog="SampleFail") - assert "query not available" in str(invalid_query.value) + collection_one = catalog_one._get_collection_obj(collection) + collection_two = catalog_two._get_collection_obj(collection) - with pytest.raises(InvalidQueryError) as invalid_query: - Catalogs.query_criteria_async(catalog="panstarrs", object_name="M10", coordinates=regionCoords, - objType="STAR") - assert "one of object_name and coordinates" in str(invalid_query.value) + assert collection_one is collection_two -def test_catalogs_query_criteria(): - # without position - result = Catalogs.query_criteria(catalog="Tic", - Bmag=[30, 50], objType="STAR") +def test_catalogs_supports_spatial_queries(patch_tap): + catalog = Catalogs() + result = catalog.supports_spatial_queries( + collection="tic", + catalog="tap_schema.schemas" + ) - assert isinstance(result, Table) + assert isinstance(result, bool) + assert result + + +def test_catalogs_verify_collection(patch_tap): + valid = Catalogs._verify_collection("tic") + assert valid.lower() == "tic" + + # Renamed collection + renamed = list(Catalogs._renamed_collections.keys())[0] + new_name = Catalogs._renamed_collections[renamed] + with pytest.warns(InputWarning, match="has been renamed"): + result = Catalogs._verify_collection(renamed) + assert result == new_name + + # Invalid collection + with pytest.raises(InvalidQueryError, match="is not recognized"): + Catalogs._verify_collection("FAKE") + + # No longer supported collection + if Catalogs._no_longer_supported_collections: + unsupported = list(Catalogs._no_longer_supported_collections)[0] + with pytest.raises(InvalidQueryError) as exc: + Catalogs._verify_collection(unsupported) + assert "no longer supported" in str(exc.value) + + +def test_catalogs_parse_inputs(patch_tap): + collection_name = Catalogs.available_collections[0] + collection_obj, catalog = Catalogs._parse_inputs(collection=collection_name, catalog=None) + assert isinstance(collection_obj, CatalogCollection) + assert collection_obj.name == collection_name + assert catalog == collection_obj.default_catalog + + # Catalog parameter warning + with pytest.warns(DeprecationWarning, match="via the `catalog` parameter is deprecated."): + collection_name = Catalogs.available_collections[0] + collection_obj, catalog = Catalogs._parse_inputs(collection=None, catalog=collection_name) + assert isinstance(collection_obj, CatalogCollection) + assert catalog == collection_obj.default_catalog + + # Use catalog attribute if valid for collection + Catalogs.catalog = "dbo.catalogrecord" + collection_obj, catalog = Catalogs._parse_inputs(collection="tic") + assert collection_obj.name == "tic" + assert catalog == "dbo.catalogrecord" + + +def test_catalogs_parse_select_cols(patch_tap): + catalog = Catalogs("tic") + column_metadata = catalog.get_column_metadata() + result = Catalogs._parse_select_cols( + ["ra", "dec"], + column_metadata) + assert result == "ra, dec" + + # Close match suggestion + close_match_col = "gaiagaiabp" + with pytest.warns(InputWarning, match=" not found in catalog. Did you mean"): + result = Catalogs._parse_select_cols( + ["ra", close_match_col], + column_metadata + ) - result = Catalogs.query_criteria(catalog="Ctl", - Bmag=[30, 50], objType="STAR") + # Empty columns + with pytest.raises(InvalidQueryError, match="No valid columns specified in `select_cols`"): + result = Catalogs._parse_select_cols( + [], + column_metadata + ) - assert isinstance(result, Table) - # with position - result = Catalogs.query_criteria(catalog="DiskDetective", - object_name="M10", radius=2, - state="complete") - assert isinstance(result, Table) +def test_catalogs_parse_legacy_pagination(patch_tap): + catalog = Catalogs("tic") + limit, offset = catalog._parse_legacy_pagination( + limit=5000, + offset=0, + pagesize=10, + page=None, + ) + assert limit == 10 + assert offset == 0 + + # Missing pagesize + with pytest.warns(InputWarning, match="The 'page' parameter is ignored without 'pagesize'."): + catalog._parse_legacy_pagination( + limit=5000, + offset=0, + pagesize=None, + page=2, + ) - with pytest.raises(InvalidQueryError) as invalid_query: - Catalogs.query_criteria(catalog="Tic", object_name="M10") - assert "non-positional" in str(invalid_query.value) +def test_catalogs_create_adql_region(patch_tap): + # String regions + adql_region = Catalogs._create_adql_region( + region="Circle 202.4656816 +47.1999842 0.2" + ) + assert adql_region == "CIRCLE('ICRS',202.4656816,+47.1999842,0.2)" -def test_catalogs_query_hsc_matchid_async(): - responses = Catalogs.query_hsc_matchid_async(82371983) - assert isinstance(responses, list) + adql_region = Catalogs._create_adql_region( + region="Polygon ICRS 202.4656816 +47.1999842 202.5656816 +47.2999842 202.3656816 +47.0999842" + ) + assert adql_region == "POLYGON('ICRS',202.4656816,+47.1999842,202.5656816,+47.2999842,202.3656816,+47.0999842)" - responses = Catalogs.query_hsc_matchid_async(82371983, version=2) - assert isinstance(responses, list) + adql_region = Catalogs._create_adql_region( + region="Polygon 202.4656816 +47.1999842 202.5656816 +47.2999842 202.3656816 +47.0999842" + ) + assert adql_region == "POLYGON('ICRS',202.4656816,+47.1999842,202.5656816,+47.2999842,202.3656816,+47.0999842)" + + # Iterable coord pairs + adql_region = Catalogs._create_adql_region( + region=[ + (57.376, 24.053), + (56.391, 24.622), + (56.025, 24.049), + (56.616, 24.291) + ] + ) + assert adql_region == "POLYGON('ICRS',57.376,24.053,56.391,24.622,56.025,24.049,56.616,24.291)" + + if HAS_REGIONS: + # Astropy region objects + cone_region = CircleSkyRegion( + center=SkyCoord(10.8, 6.5, unit="deg"), + radius=Angle(0.5, unit="deg") + ) + adql_region = Catalogs._create_adql_region(region=cone_region) + assert adql_region == "CIRCLE('ICRS',10.8,6.5,0.5)" + + polygon_region = PolygonSkyRegion( + SkyCoord( + [57.376, 56.391, 56.025, 56.616], + [24.053, 24.622, 24.049, 24.291], + frame="icrs", + unit="deg", + ) + ) + adql_region = Catalogs._create_adql_region(region=polygon_region) + assert adql_region == "POLYGON('ICRS',57.376,24.053,56.391,24.622,56.025,24.049,56.616,24.291)" + + +def test_catalogs_invalid_create_adql_region(patch_tap): + # Polygon without points + with pytest.raises(InvalidQueryError, match="Invalid POLYGON region string"): + Catalogs._create_adql_region(region="Polygon ICRS") + + # Polygon without sufficient points + with pytest.raises(InvalidQueryError, match="Invalid POLYGON region string"): + Catalogs._create_adql_region(region="Polygon ICRS 202.4656816 +47.1999842 202.5656816 +47.2999842") + + # Missing circle spec, frame not specified + with pytest.raises(InvalidQueryError, match="Invalid CIRCLE region string"): + Catalogs._create_adql_region(region="CIRCLE 202.4656816 +47.1999842") + + # Missing circle spec, frame specified + with pytest.raises(InvalidQueryError, match="Invalid CIRCLE region string"): + Catalogs._create_adql_region(region="CIRCLE ICRS 202.4656816 +47.1999842") + + # Invalid region str + with pytest.raises(InvalidQueryError, match="Unrecognized region string"): + Catalogs._create_adql_region(region="Badshape ICRS 202.4656816 +47.1999842 0.04") + + # Invalid list of coord pairs + with pytest.raises(InvalidQueryError, match="Invalid iterable region format"): + Catalogs._create_adql_region( + region=[57.376, 24.053, 56.391, 24.622, 56.025, 24.049, 56.616, 24.291] + ) + + if HAS_REGIONS: + # Invalid astropy region + with pytest.raises(TypeError, match="Unsupported region type"): + Catalogs._create_adql_region( + region=CirclePixelRegion(PixCoord(x=42, y=43), 4.2) + ) + + +def test_catalogs_parse_numeric_expression(patch_tap): + # Handling between + predicate = Catalogs._parse_numeric_expr("dec", "5..10") + assert predicate == "dec BETWEEN 5 AND 10" + + # Handling inequalities + predicate = Catalogs._parse_numeric_expr("teff", "<1") + assert predicate == "teff < 1" + + # Handling specific values + predicate = Catalogs._parse_numeric_expr("gaiabp", "1") + assert predicate == "gaiabp = 1.0" + + # Passing not numeric str + with pytest.raises(InvalidQueryError, match="is numeric; unsupported value"): + Catalogs._parse_numeric_expr("dec", "notnumeric") + + +def test_catalogs_parse_temporal_expression(patch_tap): + # Handling between + predicate = Catalogs._parse_temporal_expr("time", "2024-01-01..2024-12-31") + assert predicate == "time BETWEEN '2024-01-01 00:00:00' AND '2024-12-31 00:00:00'" + + # Handling inequalities + predicate = Catalogs._parse_temporal_expr("datetime", ">=2020-06-01") + assert predicate == "datetime >= '2020-06-01 00:00:00'" + + # Handling specific values + predicate = Catalogs._parse_temporal_expr("obs_time", "2025-04-01 12:00:00") + assert predicate == "obs_time BETWEEN '2025-04-01 12:00:00' AND '2025-04-01 12:00:01'" + + # Passing not datetime str + predicate = Catalogs._parse_temporal_expr("dec", "notdatetime") + assert predicate == "dec = 'notdatetime'" + + # Handling microseconds + predicate = Catalogs._parse_temporal_expr("datetime", ">=2020-06-01 10:00:00.0001") + assert predicate == "datetime >= '2020-06-01 10:00:00'" + + # Handling year-only str + predicate = Catalogs._parse_temporal_expr("time", "2025") + assert predicate == "time = '2025'" + + # Handling date str + predicate = Catalogs._parse_temporal_expr("time", "2020-08-01") + assert predicate == "time BETWEEN '2020-08-01 00:00:00' AND '2020-08-01 00:00:01'" + + # Handling astropy Time + predicate = Catalogs._parse_temporal_expr("obs_time", Time('2000-01-01 12:30:00')) + assert predicate == "obs_time BETWEEN '2000-01-01 12:30:00' AND '2000-01-01 12:30:01'" + + # Handling datetime + predicate = Catalogs._parse_temporal_expr("obs_time", datetime(2024, 6, 1, 8, 0, 1)) + assert predicate == "obs_time BETWEEN '2024-06-01 08:00:01' AND '2024-06-01 08:00:02'" - with pytest.warns(InputWarning) as i_w: + +def test_catalogs_format_scalar_predicate(patch_tap): + # Handling bool + predicate = Catalogs._format_scalar_predicate( + "var", True + ) + assert predicate == 'var = 1' + + # Handling num + predicate = Catalogs._format_scalar_predicate( + "e_lum", 1, is_numeric=True + ) + assert predicate == 'e_lum = 1' + + # Handling str(num) + predicate = Catalogs._format_scalar_predicate( + "e_lum", "1", is_numeric=True + ) + assert predicate == 'e_lum = 1.0' + + # Handling ! nots + predicate = Catalogs._format_scalar_predicate( + "e_lum", "!1", is_numeric=True + ) + assert predicate == 'NOT (e_lum = 1.0)' + + # Handling wildcard * + predicate = Catalogs._format_scalar_predicate( + "var", "WILDCARD*" + ) + assert predicate == "var LIKE 'WILDCARD%'" + + # Handling wildcard * and nots + predicate = Catalogs._format_scalar_predicate( + "var", "!WILDCARD*" + ) + assert predicate == "NOT (var LIKE 'WILDCARD%')" + + # Handling wildcard % + predicate = Catalogs._format_scalar_predicate( + "var", "WILDCARD%" + ) + assert predicate == "var LIKE 'WILDCARD%'" + + # Handling wildcard % and nots + predicate = Catalogs._format_scalar_predicate( + "var", "!WILDCARD%" + ) + assert predicate == "NOT (var LIKE 'WILDCARD%')" + + # Handling temporal + predicate = Catalogs._format_scalar_predicate( + "time", "!1", is_temporal=True + ) + assert predicate == "NOT (time = '1')" + + +def test_catalogs_combine_predicates(patch_tap): + # No predicates + result = Catalogs._combine_predicates([], []) + assert result == "" + + # One positive predicate + result = Catalogs._combine_predicates(["ra > 5"], []) + assert result == "ra > 5" + + # Multiple positive predicate + result = Catalogs._combine_predicates( + ["ra > 5", "dec < 0"], + [] + ) + assert result == "(ra > 5 OR dec < 0)" + + # Multiple negative predicates + result = Catalogs._combine_predicates( + [], + ["ra != 5", "dec != 0"] + ) + assert result == "ra != 5 AND dec != 0" + + # Multiple positive and negative predicates + result = Catalogs._combine_predicates( + ["ra > 5", "dec < 0"], + ["ra != 10"] + ) + assert result == "(ra != 10) AND (ra > 5 OR dec < 0)" + + +def test_catalogs_build_numeric_list_predicate(patch_tap): + # Multiple positive nums + result = Catalogs._build_numeric_list_predicate( + "ra", + pos_items=[1, 2, 3], + neg_items=[] + ) + assert result == "ra IN (1, 2, 3)" + + # Multiple positive bools + result = Catalogs._build_numeric_list_predicate( + "tessflag", + pos_items=[True, False], + neg_items=[] + ) + assert result == "tessflag IN (1, 0)" + + # Multiple positive inequalities and ranges + result = Catalogs._build_numeric_list_predicate( + "dec", + pos_items=["<5", "10..20"], + neg_items=[] + ) + assert "< 5" in result + assert "BETWEEN 10 AND 20" in result + + # Single positive num + result = Catalogs._build_numeric_list_predicate( + "ra", + pos_items=[np.int64(7)], + neg_items=[] + ) + assert "ra IN (7.0)" in result + + # Positive and negative nums + result = Catalogs._build_numeric_list_predicate( + "ra", + pos_items=[1, 2], + neg_items=[">5"] + ) + assert "ra IN (1, 2)" in result + assert "ra > 5" in result + assert "AND" in result + + # Unsupported numeric value type + with pytest.raises(InvalidQueryError, match="Unsupported numeric value type"): + Catalogs._build_numeric_list_predicate( + "ra", + pos_items=[{"not": "a number"}], + neg_items=[] + ) + + +def test_catalogs_build_string_list_predicate(patch_tap): + # Multiple positive strs + result = Catalogs._build_string_list_predicate( + "var", + pos_items=["str", "str"], + neg_items=[] + ) + assert result == "var IN ('str', 'str')" + + # Multiple positive bools + result = Catalogs._build_string_list_predicate( + "var", + pos_items=[True, False], + neg_items=[] + ) + assert result == "var IN (1, 0)" + + # Multiple positive strs with wildcard + result = Catalogs._build_string_list_predicate( + "var", + pos_items=["WILDCARD%", "str"], + neg_items=[] + ) + assert "var IN ('str')" in result + assert "LIKE 'WILDCARD%" in result + assert "OR" in result + + # Multiple positive nums + result = Catalogs._build_string_list_predicate( + "var", + pos_items=[1, 0], + neg_items=[] + ) + assert result == "var IN (1, 0)" + + +def test_catalogs_build_temporal_list_predicate(patch_tap): + # Multiple positive temps + result = Catalogs._build_temporal_list_predicate( + "var", + pos_items=["<2020-06-01", ">2025-06-01"], + neg_items=[] + ) + assert result == "(var < '2020-06-01 00:00:00' OR var > '2025-06-01 00:00:00')" + + # Multiple positive inequalities and ranges + result = Catalogs._build_temporal_list_predicate( + "var", + pos_items=[">2025-06-01", "2020-06-01..2020-12-31"], + neg_items=[] + ) + assert "var > '2025-06-01 00:00:00'" in result + assert "BETWEEN '2020-06-01 00:00:00' AND '2020-12-31 00:00:00'" in result + + +def test_catalogs_format_criteria_conditions(patch_tap): + # Multiple numeric cols and singular criteria + criteria = {"ra": 5, "dec": 10} + result = Catalogs._format_criteria_conditions( + CatalogCollection("tic"), + "dbo.catalogrecord", + criteria + ) + assert result == ["ra = 5", "dec = 10"] + + # Str cols and singular criteria + criteria = {"obj_type": "STAR"} + result = Catalogs._format_criteria_conditions( + CatalogCollection("tic"), + "dbo.catalogrecord", + criteria + ) + assert result == ["obj_type = 'STAR'"] + + # Multiple cols and multiple criteria + criteria = {"ra": [1, 2, 3], "dec": [">5", "<10"]} + result = Catalogs._format_criteria_conditions( + CatalogCollection("tic"), + "dbo.catalogrecord", + criteria + ) + assert any("ra IN" in r or "ra >" in r for r in result) + assert any("dec <" in r or "dec >" in r for r in result) + + # Str cols and multiple criteria + criteria = {"obj_type": ["STAR", "!GALAXY"]} + result = Catalogs._format_criteria_conditions( + CatalogCollection("tic"), + "dbo.catalogrecord", + criteria + ) + assert any("NOT" in r for r in result) + assert any("STAR" in r for r in result) + + # Empty criteria + criteria = {"ra": []} + result = Catalogs._format_criteria_conditions( + CatalogCollection("tic"), + "dbo.catalogrecord", + criteria + ) + assert result == ["1=0"] + + +def test_catalogs_invalid_tap_query(patch_tap): + # This will trigger a DALQueryError in the mock TAP service + # when 'invalid' is found in the query string + with pytest.raises(InvalidQueryError, match="Simulated TAP query error"): + Catalogs.query_criteria( + collection="tic", + coordinates=regionCoords, + radius=0.002 * u.deg, + allwise='invalid' + ) + + with pytest.raises(InvalidQueryError, match="Simulated TAP query error"): + Catalogs.query_region( + regionCoords, + radius=0.002 * u.deg, + collection="tic", + allwise='invalid' + ) + + with pytest.raises(InvalidQueryError, match="Simulated TAP query error"): + Catalogs.query_object( + "M10", + radius=.001, + collection="TIC", + allwise='invalid' + ) + + # Simulate a timeout + with pytest.raises(RuntimeError, match="TAP query timed out for collection 'tic'"): + Catalogs.query_criteria( + collection="tic", + coordinates=regionCoords, + radius=0.002 * u.deg, + allwise='timeout' + ) + + +def test_catalogs_invalid_spatial_query(patch_tap): + # Force spatial query to fail + patch_tap.search = MagicMock(side_effect=DALQueryError("spatial failed")) + with pytest.raises(InvalidQueryError, match="does not support spatial queries"): + Catalogs.query_criteria( + collection="tic", + coordinates=regionCoords, + radius=0.002 * u.deg, + ) + + +def test_catalogs_query_hsc_matchid_async(patch_post): + with pytest.warns(AstropyDeprecationWarning, match="This function is deprecated"): + responses = Catalogs.query_hsc_matchid_async(82371983) + assert isinstance(responses, list) + + with pytest.warns(AstropyDeprecationWarning, match="This function is deprecated"): + responses = Catalogs.query_hsc_matchid_async(82371983, version=2) + assert isinstance(responses, list) + + with pytest.warns((AstropyDeprecationWarning, InputWarning)) as record: Catalogs.query_hsc_matchid_async(82371983, version=5) - assert "Invalid HSC version number" in str(i_w[0].message) + messages = [str(w.message) for w in record] + assert any("This function is deprecated" in m for m in messages) + assert any("Invalid HSC version number" in m for m in messages) -def test_catalogs_query_hsc_matchid(): - result = Catalogs.query_hsc_matchid(82371983) - assert isinstance(result, Table) +def test_catalogs_query_hsc_matchid(patch_post): + with pytest.warns(AstropyDeprecationWarning, match="This function is deprecated"): + result = Catalogs.query_hsc_matchid(82371983) + assert isinstance(result, Table) -def test_catalogs_get_hsc_spectra_async(): - responses = Catalogs.get_hsc_spectra_async() - assert isinstance(responses, list) +def test_catalogs_get_hsc_spectra_async(patch_post): + with pytest.warns(AstropyDeprecationWarning, match="This function is deprecated"): + responses = Catalogs.get_hsc_spectra_async() + assert isinstance(responses, list) -def test_catalogs_get_hsc_spectra(): - result = Catalogs.get_hsc_spectra() - assert isinstance(result, Table) +def test_catalogs_get_hsc_spectra(patch_post): + with pytest.warns(AstropyDeprecationWarning, match="This function is deprecated"): + result = Catalogs.get_hsc_spectra() + assert isinstance(result, Table) -def test_catalogs_download_hsc_spectra(tmpdir): - allSpectra = Catalogs.get_hsc_spectra() +def test_catalogs_download_hsc_spectra(patch_post, tmpdir): + with pytest.warns(AstropyDeprecationWarning, match="This function is deprecated"): + allSpectra = Catalogs.get_hsc_spectra() + + # Actually download the products + result = Catalogs.download_hsc_spectra(allSpectra[10], download_dir=str(tmpdir)) + assert isinstance(result, Table) + + # Just get the curl script + result = Catalogs.download_hsc_spectra(allSpectra[20:24], + download_dir=str(tmpdir), curl_flag=True) + assert isinstance(result, Table) + + +########################### +# CatalogCollection tests # +########################### - # actually download the products - result = Catalogs.download_hsc_spectra(allSpectra[10], download_dir=str(tmpdir)) - assert isinstance(result, Table) - # just get the curl script - result = Catalogs.download_hsc_spectra(allSpectra[20:24], - download_dir=str(tmpdir), curl_flag=True) +def test_catalog_collection_discover_collections(patch_tap): + collections = CatalogCollection.discover_collections() + assert isinstance(collections, Table) + assert len(collections) > 0 + assert "collection_name" in collections.colnames + assert "parent_collection" in collections.colnames + + +def test_catalog_collection_get_parent_collection(patch_tap): + parent = CatalogCollection.get_parent_collection("tic") + assert parent == "tic" + + # Error if collection not a string + with pytest.raises(InvalidQueryError): + CatalogCollection.get_parent_collection(123) + + # Error if collection not found + with pytest.raises(InvalidQueryError, match="Collection 'fake' not found"): + CatalogCollection.get_parent_collection("fake") + + +def test_catalog_collection_tap_get_catalog_metadata(patch_tap): + cc = CatalogCollection("tic") + default_catalog = cc.get_default_catalog() + default_metadata = cc.get_catalog_metadata(default_catalog) + assert isinstance(default_metadata, CatalogMetadata) + assert isinstance(default_metadata.column_metadata, Table) + assert isinstance(default_metadata.ra_column, str) + assert isinstance(default_metadata.dec_column, str) + assert isinstance(default_metadata.supports_spatial_queries, bool) + + assert len(default_metadata.column_metadata) > 0 + assert default_metadata.ra_column in default_metadata.column_metadata["column_name"] + assert default_metadata.dec_column in default_metadata.column_metadata["column_name"] + + metadata_cached = cc.get_catalog_metadata(default_catalog) + assert default_metadata is metadata_cached + + +def test_catalog_collection_get_default_catalog(patch_tap): + cc = CatalogCollection("tic") + catalogs = cc._fetch_catalogs() + default = cc.get_default_catalog() + + assert isinstance(catalogs, Table) + assert len(catalogs) > 0 + assert catalogs.colnames == ['catalog_name', 'description'] + + assert not default.startswith("tap_schema") + assert default.casefold() in [name.casefold() for name in catalogs["catalog_name"]] + assert DEFAULT_CATALOGS["tic"] == default + + # First non-tap_schema + cc = CatalogCollection("tic") + cc.name = "fake" + fake_catalogs = Table({ + "catalog_name": [ + "tap_schema.tables", + "tap_schema.columns", + "real_catalog", + "real_catalog_2", + ], + "description": ["", "", "", ""], + }) + cc._fetch_catalogs = MagicMock(return_value=fake_catalogs) + assert isinstance(catalogs, Table) + assert len(catalogs) > 0 + + default = cc.get_default_catalog() + assert default == "real_catalog" + + # All are tap_schema + cc = CatalogCollection("tic") + cc.name = "fake" + fake_catalogs = Table({ + "catalog_name": [ + "tap_schema.tables", + "tap_schema.columns", + ], + "description": ["", ""], + }) + cc._fetch_catalogs = MagicMock(return_value=fake_catalogs) + assert isinstance(catalogs, Table) + assert len(catalogs) > 0 + + default = cc.get_default_catalog() + assert default == "tap_schema.tables" + + +def test_catalog_collection_run_tap_query(patch_tap): + cc = CatalogCollection("tic") + + adql_str = ( + "SELECT TOP 10 solution_id, designation, source_id, ra, dec " + "FROM gaia_source WHERE " + "ra BETWEEN 10 AND 11 AND dec BETWEEN 12 AND 13" + ) + result = cc.run_tap_query(adql_str) + assert isinstance(result, Table) + assert len(result) > 0 + + query = get_patch_tap_query(patch_tap) + assert adql_str in query + + +def test_catalog_collection_invalid_run_tap_query(patch_tap): + cc = CatalogCollection("tic") + with pytest.raises(InvalidQueryError, match="TAP query failed for collection 'tic'"): + adql_str = "invalid" + cc.run_tap_query(adql_str) + + +def test_catalog_collection_grouped_fetch_catalogs(patch_tap): + name = "dbo" + cc = CatalogCollection(name) + _ = cc._fetch_catalogs() + query = get_patch_tap_query(patch_tap) + assert f"WHERE table_name LIKE '{name}" in query + + +def test_catalog_collection_verify_catalog(patch_tap): + cc = CatalogCollection("tic") + default_catalog = cc.get_default_catalog() + + # Valid catalog + assert isinstance(cc._verify_catalog(default_catalog), str) + assert cc._verify_catalog(default_catalog) == 'dbo.catalogrecord' + + +def test_catalog_collection_invalid_verify_catalog(patch_tap): + cc = CatalogCollection("tic") + + # Ambiguous + fake_catalogs = Table({ + "catalog_name": [ + "mission1.catalogA", + "mission2.catalogA", + ], + "description": ["", ""], + }) + cc._fetch_catalogs = MagicMock(return_value=fake_catalogs) + + with pytest.raises(InvalidQueryError, match="is ambiguous for collection"): + cc._verify_catalog("catalogA") + + # Invalid catalog + with pytest.raises(InvalidQueryError, match="Catalog 'fake' is not recognized for collection 'tic'"): + cc._verify_catalog("fake") + + +def test_catalog_collection_invalid_get_column_metadata(patch_tap): + cc = CatalogCollection("tic") + + empty_result = Table( + names=["column_name", "datatype", "unit", "ucd", "description"] + ) + cc.tap_service.run_sync = MagicMock(return_value=empty_result) + + with pytest.raises( + InvalidQueryError, + match="Catalog 'fake_catalog' not found in collection 'tic'" + ): + cc._get_column_metadata("fake_catalog") + + +def test_catalog_collection_verify_criteria(patch_tap): + cc = CatalogCollection("tic") + default_catalog = cc.get_default_catalog() + + # Valid filters + assert cc._verify_criteria(default_catalog) is None + assert cc._verify_criteria(default_catalog, gaiabp=1) is None + assert cc._verify_criteria(default_catalog, gaiabp=1, teff=1) is None + + +def test_catalog_collection_invalid_verify_criteria(patch_tap): + cc = CatalogCollection("tic") + default_catalog = cc.get_default_catalog() + + close_match_col = "gaiagaiabp" + with pytest.raises(InvalidQueryError, match=f"Filter '{close_match_col}' is not recognized for collection " + f"'{cc.name}' and catalog '{default_catalog}'. Did you mean 'gaiabp'?"): + cc._verify_criteria(default_catalog, gaiagaiabp=1) + + invalid_col = "fake_column" + with pytest.raises(InvalidQueryError, match=f"Filter '{invalid_col}' is not recognized for collection " + f"'{cc.name}' and catalog '{default_catalog}'."): + cc._verify_criteria(default_catalog, fake_column=1) + + +def test_catalog_collection_invalid_spatial_query(patch_tap): + cc = CatalogCollection("tic") + + # Force only spatial query to fail + patch_tap.search = MagicMock(side_effect=DALQueryError("spatial failed")) + default_catalog = cc.get_default_catalog() + metadata = cc.get_catalog_metadata(default_catalog) + + assert metadata.supports_spatial_queries is False + assert patch_tap.search.called + + +def test_catalog_collection_invalid_collection_type(patch_tap): + # Error if collection name is not a string + with pytest.raises(ValueError, match="Collection name must be a string, got "): + CatalogCollection(123) ###################### diff --git a/astroquery/mast/tests/test_mast_remote.py b/astroquery/mast/tests/test_mast_remote.py index b6f55a05e2..e14dba2656 100644 --- a/astroquery/mast/tests/test_mast_remote.py +++ b/astroquery/mast/tests/test_mast_remote.py @@ -1,24 +1,38 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst +import json import logging +import os from pathlib import Path + +import astropy.units as u import numpy as np -import os import pytest -import json - -from requests.models import Response - -from astropy.table import Table, unique from astropy.coordinates import SkyCoord from astropy.io import fits -import astropy.units as u - -from astroquery.mast import Observations, utils, Mast, Catalogs, Hapcut, Tesscut, Zcut, MastMissions +from astropy.table import Table, unique +from astropy.time import Time +from requests.models import Response +from astroquery.mast import ( + Catalogs, + Hapcut, + Mast, + MastMissions, + Observations, + Tesscut, + Zcut, + utils, +) + +from ...exceptions import ( + InputWarning, + InvalidQueryError, + MaxResultsWarning, + NoResultsWarning, +) +from ..catalog_collection import DEFAULT_CATALOGS, CatalogCollection, CatalogMetadata from ..utils import ResolverError -from ...exceptions import (InputWarning, InvalidQueryError, MaxResultsWarning, - NoResultsWarning) @pytest.fixture(scope="module") @@ -1052,388 +1066,386 @@ def test_observations_get_cloud_uris_no_duplicates(self, msa_product_table, rese # CatalogClass tests # ###################### - # query functions - def test_catalogs_query_region_async(self): - in_rad = 0.001 * u.deg - responses = Catalogs.query_region_async("158.47924 -7.30962", - radius=in_rad, - catalog="Galex") - assert isinstance(responses, list) - - # Default catalog is HSC - responses = Catalogs.query_region_async("322.49324 12.16683", - radius=in_rad) - assert isinstance(responses, list) + def test_catalogs_collection(self): + # Default collection should be HSC + c = Catalogs() + assert c.collection == "hsc" + assert c.catalog == "dbo.SumMagAper2CatView" + + # Initialize with a different collection + c = Catalogs(collection="gaiadr3") + assert c.collection == "gaiadr3" + assert c.catalog == "dbo.gaia_source" + + # Initialize with a different collection and catalog + c = Catalogs(collection="ullyses", catalog="publications") + assert c.collection == "ullyses" + assert c.catalog == "dbo.publications" + + # Set the collection + c.collection = "tic_v82" + assert c.collection == "tic_v82" + assert c.catalog == "tic_v82.source" + + def test_catalogs_get_collections(self): + collections = Catalogs.get_collections() + assert isinstance(collections, Table) + assert "collection_name" in collections.colnames + + def test_catalogs_get_catalogs(self): + catalogs = Catalogs.get_catalogs() + assert isinstance(catalogs, Table) + assert "catalog_name" in catalogs.colnames + assert "description" in catalogs.colnames + assert not any(catalog.startswith("tap_schema") for catalog in catalogs["catalog_name"]) + + def test_catalogs_get_column_metadata(self): + metadata = Catalogs.get_column_metadata("hsc", "dbo.SumMagAper2CatView") + assert isinstance(metadata, Table) + assert len(metadata) > 0 + assert "column_name" in metadata.colnames + assert "datatype" in metadata.colnames + assert "unit" in metadata.colnames + assert "ucd" in metadata.colnames + assert "description" in metadata.colnames + + def test_catalogs_supports_spatial_queries(self): + assert Catalogs.supports_spatial_queries("hsc", "dbo.SumMagAper2CatView") + assert not Catalogs.supports_spatial_queries("ullyses", "dbo.publications") - responses = Catalogs.query_region_async("322.49324 12.16683", - radius=in_rad, - catalog="panstarrs", - table="mean") - assert isinstance(responses, Response) - - def test_catalogs_query_region(self): - def check_result(result, row, exp_values): - assert isinstance(result, Table) - for k, v in exp_values.items(): - assert result[row][k] == v - - in_radius = 0.1 * u.deg - result = Catalogs.query_region("158.47924 -7.30962", - radius=in_radius, - catalog="Gaia") - row = np.where(result['source_id'] == '3774902350511581696') - check_result(result, row, {'solution_id': '1635721458409799680'}) - - result = Catalogs.query_region("322.49324 12.16683", - radius=0.001*u.deg, - catalog="HSC", - magtype=2) - row = np.where(result['MatchID'] == '8150896') - - with pytest.warns(MaxResultsWarning): - result = Catalogs.query_region("322.49324 12.16683", catalog="HSC", magtype=2, nr=5) - - check_result(result, row, {'NumImages': 14, 'TargetName': 'M15'}) - - result = Catalogs.query_region("322.49324 12.16683", - radius=0.001*u.deg, - catalog="HSC", - version=2, - magtype=2) - row = np.where(result['MatchID'] == '82361658') - check_result(result, row, {'NumImages': 11, 'TargetName': 'NGC7078'}) - - result = Catalogs.query_region("322.49324 12.16683", - radius=in_radius, - catalog="Gaia", - version=1) - row = np.where(result['source_id'] == '1745948323734098688') - check_result(result, row, {'solution_id': '1635378410781933568'}) - result = Catalogs.query_region("322.49324 12.16683", - radius=0.01*u.deg, - catalog="Gaia", - version=2) - - row = np.where(result['source_id'] == '1745947739618544000') - check_result(result, row, {'solution_id': '1635721458409799680'}) - - result = Catalogs.query_region("322.49324 12.16683", - radius=0.01*u.deg, catalog="panstarrs", - table="mean", - columns=['objName', 'objID', 'yFlags', 'distance']) - row = np.where((result['objName'] == 'PSO J322.4622+12.1920') & (result['yFlags'] == 16777496)) - assert isinstance(result, Table) - np.testing.assert_allclose(result[row]['distance'], 0.039381703406789904) - - result = Catalogs.query_region("158.47924 -7.30962", - radius=in_radius, - catalog="Galex") - in_radius_arcmin = 0.1*u.deg.to(u.arcmin) - distances = list(result['distance_arcmin']) - assert isinstance(result, Table) - assert max(distances) <= in_radius_arcmin - - result = Catalogs.query_region("158.47924 -7.30962", - radius=in_radius, - catalog="tic") - row = np.where(result['ID'] == '841736289') - second_id = result[1]['ID'] - check_result(result, row, {'gaiaqflag': 1}) - np.testing.assert_allclose(result[row]['RA_orig'], 158.475246786483) - - result = Catalogs.query_region("158.47924 -7.30962", - radius=in_radius, - catalog="tic", - pagesize=1, - page=2) + def test_catalogs_query_criteria(self): + # Positional query with multiple filters + c = Catalogs() + search_coord = SkyCoord(322.49324, 12.16683, unit="deg") + select_cols = ["matchid", "matchra", "matchdec", "numimages", "starttime", "targetname"] + result = c.query_criteria( + coordinates=search_coord, + radius="2 arcsec", + sort_by=["numimages", "starttime"], + sort_desc=[False, True], + targetname=["M-15", "NGC*"], + starttime=">2010", + limit=5, + select_cols=select_cols, + ) assert isinstance(result, Table) - assert len(result) == 1 - assert second_id == result[0]['ID'] - - result = Catalogs.query_region("158.47924 -7.30962", - radius=in_radius, - catalog="ctl") - row = np.where(result['ID'] == '56662064') - check_result(result, row, {'TYC': '4918-01335-1'}) - - result = Catalogs.query_region("210.80227 54.34895", - radius=1*u.deg, - catalog="diskdetective") - row = np.where(result['designation'] == 'J140544.95+535941.1') - check_result(result, row, {'ZooniverseID': 'AWI0000r57'}) - - def test_catalogs_query_object_async(self): - responses = Catalogs.query_object_async("M10", - radius=.02, - catalog="TIC") - assert isinstance(responses, list) - - def test_catalogs_query_object(self): - def check_result(result, exp_values): - assert isinstance(result, Table) - for k, v in exp_values.items(): - assert v in result[k] - - result = Catalogs.query_object("M10", - radius=.001, - catalog="TIC") - check_result(result, {'ID': '1305764225'}) - second_id = result[1]['ID'] - - result = Catalogs.query_object("M10", - radius=.001, - catalog="TIC", - pagesize=1, - page=2) + assert len(result) == 5 + assert all(c in result.colnames for c in select_cols) + assert all(val == "M-15" or str(val).startswith("NGC") for val in result["targetname"]) + assert all(result["starttime"] > Time("2010-01-01T00:00:00")) + # Assert that all results are within 2 arcsec of the specified coordinates + coords = SkyCoord(result["matchra"], result["matchdec"], unit="deg") + separation = coords.separation(search_coord) + assert all(separation <= 2 * u.arcsec) + # Assert that results are sorted by numimages ascending and then starttime descending + assert all(result["numimages"][i] <= result["numimages"][i + 1] for i in range(len(result) - 1)) + assert all( + result["starttime"][i] >= result["starttime"][i + 1] + for i in range(len(result) - 1) + if result["numimages"][i] == result["numimages"][i + 1] + ) + # Assert that the ADQL query is included in the metadata and contains the correct filters + assert "adql_query" in result.meta + assert "SELECT" in result.meta["adql_query"] + + # Non-positional query with multiple filters and select_cols + select_cols = [ + "target_name_ullyses", + "target_classification", + "known_binary", + "sp_class", + "gaia_parallax", + "star_teff", + "coordinate_epoch", + "spectral_type_ref", + ] + result = c.query_criteria( + collection="ullyses", + catalog="sciencemetadata", + target_name_ullyses="NGC*", + target_classification=["!Galaxy", "!Late O Dwarf"], + known_binary=False, + sp_class=["O", "B"], + gaia_parallax=["<-0.01", ">=0", "!<-0.3"], + star_teff="30000..50000", + coordinate_epoch=2016, + spectral_type_ref=[51, 18, 59], + select_cols=select_cols, + ) assert isinstance(result, Table) - assert len(result) == 1 - assert second_id == result[0]['ID'] - - result = Catalogs.query_object("M10", - radius=.001, - catalog="HSC", - magtype=1) - check_result(result, {'MatchID': '667727'}) - - result = Catalogs.query_object("M10", - radius=.001, - catalog="panstarrs", - table="mean", - columns=['objName', 'objID']) - check_result(result, {'objName': 'PSO J254.2873-04.1006'}) - - result = Catalogs.query_object("M10", - radius=0.18, - catalog="diskdetective") - check_result(result, {'designation': 'J165749.79-040315.1'}) - - result = Catalogs.query_object("M10", - radius=0.001, - catalog="Gaia", - version=1) - distances = list(result['distance']) - radius_arcmin = 0.01 * u.deg.to(u.arcmin) + assert len(result) > 0 + assert all(str(val).startswith("NGC") for val in result["target_name_ullyses"]) + assert all(result["target_classification"] != "Galaxy") + assert all(result["target_classification"] != "Late O Dwarf") + assert not any(result["known_binary"]) + assert all(np.isin(result["sp_class"], ["O", "B"])) + assert all( + ((result["gaia_parallax"] < -0.01) | (result["gaia_parallax"] >= 0)) + & ~(result["gaia_parallax"] < -0.3) + ) + assert all((result["star_teff"] >= 30000) & (result["star_teff"] <= 50000)) + assert all(result["coordinate_epoch"] == 2016) + assert all(np.isin(result["spectral_type_ref"], [51, 18, 59])) + assert all(c in result.colnames for c in select_cols) + + # Test offset + result = c.query_criteria(collection="hsc", numimages=8, sort_by="matchid", limit=5) + result_offset = c.query_criteria(collection="hsc", numimages=8, sort_by="matchid", limit=5, offset=2) + assert result_offset["matchid"][0] == result["matchid"][2] + + # Count only + result_count = c.query_criteria(collection="goods", class_star=0.23, count_only=True) + assert isinstance(result_count, (int, np.integer)) + assert result_count > 0 + + # Return ADQL + result_adql = c.query_criteria(collection="goods", class_star=0.23, return_adql=True) + assert isinstance(result_adql, str) + assert "SELECT" in result_adql + + # Run async + result_async = c.query_criteria(collection="goods", class_star=0.23, run_async=True) + assert isinstance(result_async, Table) + assert len(result_async) > 0 + assert all(result_async["class_star"] == 0.23) + + # Temporal filters and filter passed in through filters argument + result = c.query_criteria(collection='caom', + catalog='caommembers', + limit=10, + recordcreated=['>2014-01-01T00:00:00', '<2000'], + recordmodified='2021-03-01..2021-03-31', + filters={'collection': 'GALEX'}) assert isinstance(result, Table) - assert max(distances) < radius_arcmin - - result = Catalogs.query_object("TIC 441662144", - radius=0.001, - catalog="ctl") - check_result(result, {'ID': '441662144'}) - - result = Catalogs.query_object('M10', - radius=0.08, - catalog='plato') - assert 'PICidDR1' in result.colnames - - def test_catalogs_query_criteria_async(self): - # without position - responses = Catalogs.query_criteria_async(catalog="Tic", - Bmag=[30, 50], - objType="STAR") - assert isinstance(responses, list) - - responses = Catalogs.query_criteria_async(catalog="ctl", - Bmag=[30, 50], - objType="STAR") - assert isinstance(responses, list) - - responses = Catalogs.query_criteria_async(catalog="DiskDetective", - state=["inactive", "disabled"], - oval=[8, 10], - multi=[3, 7]) - assert isinstance(responses, list) - - # with position - responses = Catalogs.query_criteria_async(catalog="Tic", - object_name="M10", - objType="EXTENDED") - assert isinstance(responses, list) - - responses = Catalogs.query_criteria_async(catalog="CTL", - object_name="M10", - objType="EXTENDED") - assert isinstance(responses, list) - - responses = Catalogs.query_criteria_async(catalog="DiskDetective", - object_name="M10", - radius=2, - state="complete") - assert isinstance(responses, list) - - responses = Catalogs.query_criteria_async(catalog="panstarrs", - table="mean", - object_name="M10", - radius=.02, - qualityFlag=48) - assert isinstance(responses, Response) + assert len(result) <= 10 + assert all(c in result.colnames for c in ['recordcreated', 'collection']) + assert all( + (result['recordcreated'] > Time('2014-01-01T00:00:00')) + | (result['recordcreated'] < Time('2000-01-01T00:00:00')) + ) + assert all((result['recordmodified'] >= Time('2021-03-01T00:00:00')) + & (result['recordmodified'] <= Time('2021-03-31T23:59:59'))) + assert all(result['collection'] == 'GALEX') + + def test_catalogs_query_criteria_error(self): + # No results should warn user + with pytest.warns(NoResultsWarning): + Catalogs.query_criteria(collection="classy", z="<0") - def test_catalogs_query_criteria(self): - def check_result(result, exp_vals): - assert isinstance(result, Table) - for k, v in exp_vals.items(): - assert v in result[k] + with pytest.warns(NoResultsWarning): + Catalogs.query_criteria(collection="classy", target=[]) - # without position - result = Catalogs.query_criteria(catalog="Tic", - Bmag=[30, 50], - objType="STAR") - check_result(result, {'ID': '81609218'}) - second_id = result[1]['ID'] - - result = Catalogs.query_criteria(catalog="Tic", - Bmag=[30, 50], - objType="STAR", - pagesize=1, - page=2) + def test_catalogs_query_region(self): + # Region search with polygon + select_cols = ["object_id", "raj2000", "dej2000"] + result = Catalogs.query_region( + collection="skymapperdr4", + region="POLYGON ICRS 18.85 -6.95 18.86 -6.95 18.86 -6.94 18.85 -6.94", + limit=5, + select_cols=select_cols, + ) assert isinstance(result, Table) - assert len(result) == 1 - assert second_id == result[0]['ID'] - - result = Catalogs.query_criteria(catalog="ctl", - Tmag=[10.5, 11], - POSflag="2mass") - check_result(result, {'ID': '291067184'}) - - result = Catalogs.query_criteria(catalog="DiskDetective", - state=["inactive", "disabled"], - oval=[8, 10], - multi=[3, 7]) - check_result(result, {'designation': 'J003920.04-300132.4'}) - - # with position - result = Catalogs.query_criteria(catalog="Tic", - object_name="M10", objType="EXTENDED") - check_result(result, {'ID': '10000732589'}) - - result = Catalogs.query_criteria(object_name='TIC 291067184', - catalog="ctl", - Tmag=[10.5, 11], - POSflag="2mass") - check_result(result, {'Tmag': 10.893}) - - result = Catalogs.query_criteria(catalog="DiskDetective", - object_name="M10", - radius=2, - state="complete") - check_result(result, {'designation': 'J165628.40-054630.8'}) - - result = Catalogs.query_criteria(catalog="panstarrs", - object_name="M10", - radius=.01, - qualityFlag=32, - zoneID=10306, - columns=['objName', 'objID']) - check_result(result, {'objName': 'PSO J254.2861-04.1091'}) - - result = Catalogs.query_criteria(coordinates="158.47924 -7.30962", - radius=0.01, - catalog="PANSTARRS", - table="mean", - data_release="dr2", - nStackDetections=[("gte", "1")], - columns=["objName", "distance"], - sort_by=[("asc", "distance")]) + assert len(result) <= 5 + assert all(c in result.colnames for c in select_cols) + # Assert that all results are within a radius of the specified polygon + # We can't just check that the coordinates are within the polygon because + # they may intersect the polygon without the center being within it + coords = SkyCoord(result["raj2000"], result["dej2000"], unit="deg") + polygon = SkyCoord(18.855, -6.945, unit="deg") + separation = coords.separation(polygon) + assert all(separation <= 0.1 * u.deg) + + # Region search with circle + result = Catalogs.query_region( + collection="skymapperdr4", region="CIRCLE ICRS 18.85 -6.95 0.01", limit=5, select_cols=select_cols + ) assert isinstance(result, Table) - assert result['distance'][0] <= result['distance'][1] - - # with case-insensitive keyword arguments - result = Catalogs.query_criteria(catalog="Tic", - bMAG=[30, 50], - objtype="STAR") - check_result(result, {'ID': '81609218'}) - - result = Catalogs.query_criteria(catalog="DiskDetective", - STATE=["inactive", "disabled"], - oVaL=[8, 10], - Multi=[3, 7]) - check_result(result, {'designation': 'J003920.04-300132.4'}) - - def test_catalogs_query_criteria_invalid_keyword(self): - # attempt to make a criteria query with invalid keyword - with pytest.raises(InvalidQueryError) as err_no_alt: - Catalogs.query_criteria(catalog='tic', not_a_keyword='TESS') - assert "Filter 'not_a_keyword' does not exist." in str(err_no_alt.value) - - # keyword is close enough for difflib to offer alternative - with pytest.raises(InvalidQueryError) as err_with_alt: - Catalogs.query_criteria(catalog='ctl', objectType="STAR") - assert 'objType' in str(err_with_alt.value) - - # region query with invalid keyword - with pytest.raises(InvalidQueryError) as err_region: - Catalogs.query_region('322.49324 12.16683', - radius=0.001*u.deg, - catalog='HSC', - invalid=2) - assert "Filter 'invalid' does not exist for catalog HSC." in str(err_region.value) - - # panstarrs criteria query with invalid keyword - with pytest.raises(InvalidQueryError) as err_ps_criteria: - Catalogs.query_criteria(coordinates="158.47924 -7.30962", - catalog="PANSTARRS", - table="mean", - data_release="dr2", - columns=["objName", "distance"], - sort_by=[("asc", "distance")], - obj_name='invalid') - assert 'objName' in str(err_ps_criteria.value) + assert len(result) <= 5 + assert all(c in result.colnames for c in select_cols) + # Assert that all results are within a radius of the specified circle + coords = SkyCoord(result["raj2000"], result["dej2000"], unit="deg") + center = SkyCoord(18.85, -6.95, unit="deg") + separation = coords.separation(center) + assert all(separation <= 0.1 * u.deg) + def test_catalogs_query_object(self): + # Object search + select_cols = ["object_id", "raj2000", "dej2000"] + result = Catalogs.query_object(collection="skymapperdr4", object_name="M2", limit=5, select_cols=select_cols) + assert isinstance(result, Table) + assert len(result) <= 5 + assert all(c in result.colnames for c in select_cols) + # Assert that all results are within a radius of the specified object + coords = SkyCoord(result["raj2000"], result["dej2000"], unit="deg") + m2_coords = SkyCoord(323.36258, -0.82325, unit="deg") + separation = coords.separation(m2_coords) + assert all(separation <= 0.1 * u.deg) + + @pytest.mark.filterwarnings("ignore::astropy.utils.exceptions.AstropyDeprecationWarning") def test_catalogs_query_hsc_matchid_async(self): - catalogData = Catalogs.query_object("M10", - radius=.001, - catalog="HSC", - magtype=1) + catalogData = Catalogs.query_object("M10", radius=0.001, collection="HSC") responses = Catalogs.query_hsc_matchid_async(catalogData[0]) assert isinstance(responses, list) - responses = Catalogs.query_hsc_matchid_async(catalogData[0]["MatchID"]) + responses = Catalogs.query_hsc_matchid_async(catalogData[0]["matchid"]) assert isinstance(responses, list) + @pytest.mark.filterwarnings("ignore::astropy.utils.exceptions.AstropyDeprecationWarning") def test_catalogs_query_hsc_matchid(self): - catalogData = Catalogs.query_object("M10", - radius=.001, - catalog="HSC", - magtype=1) - matchid = catalogData[0]["MatchID"] + catalogData = Catalogs.query_object("M10", radius=0.001, collection="HSC") + matchid = str(catalogData[0]["matchid"]) result = Catalogs.query_hsc_matchid(catalogData[0]) assert isinstance(result, Table) - assert (result['MatchID'] == matchid).all() + assert (result["MatchID"].value == matchid).all() result2 = Catalogs.query_hsc_matchid(matchid) assert isinstance(result2, Table) assert len(result2) == len(result) - assert (result2['MatchID'] == matchid).all() + assert (result2["MatchID"] == matchid).all() + @pytest.mark.filterwarnings("ignore::astropy.utils.exceptions.AstropyDeprecationWarning") def test_catalogs_get_hsc_spectra_async(self): responses = Catalogs.get_hsc_spectra_async() assert isinstance(responses, list) + @pytest.mark.filterwarnings("ignore::astropy.utils.exceptions.AstropyDeprecationWarning") def test_catalogs_get_hsc_spectra(self): result = Catalogs.get_hsc_spectra() assert isinstance(result, Table) - assert result[np.where(result['MatchID'] == '19657846')] - assert result[np.where(result['DatasetName'] == 'HAG_J072657.06+691415.5_J8HPAXAEQ_V01.SPEC1D')] + assert result[np.where(result["MatchID"] == "19657846")] + assert result[np.where(result["DatasetName"] == "HAG_J072657.06+691415.5_J8HPAXAEQ_V01.SPEC1D")] + @pytest.mark.filterwarnings("ignore::astropy.utils.exceptions.AstropyDeprecationWarning") def test_catalogs_download_hsc_spectra(self, tmpdir): allSpectra = Catalogs.get_hsc_spectra() # actually download the products - result = Catalogs.download_hsc_spectra(allSpectra[10], - download_dir=str(tmpdir)) + result = Catalogs.download_hsc_spectra(allSpectra[10], download_dir=str(tmpdir)) assert isinstance(result, Table) for row in result: - if row['Status'] == 'COMPLETE': - assert os.path.isfile(row['Local Path']) + if row["Status"] == "COMPLETE": + assert os.path.isfile(row["Local Path"]) # just get the curl script - result = Catalogs.download_hsc_spectra(allSpectra[20:24], - download_dir=str(tmpdir), curl_flag=True) + result = Catalogs.download_hsc_spectra(allSpectra[20:24], download_dir=str(tmpdir), curl_flag=True) + assert isinstance(result, Table) + assert os.path.isfile(result["Local Path"][0]) + + ########################### + # CatalogCollection tests # + ########################### + + def test_catalog_collection_discover_collections(self): + collections = CatalogCollection.discover_collections() + assert isinstance(collections, Table) + assert len(collections) > 0 + assert "collection_name" in collections.colnames + assert "parent_collection" in collections.colnames + assert CatalogCollection._discovered_collections is not None + + def test_catalog_collection_get_parent_collection(self): + parent = CatalogCollection.get_parent_collection("gaiadr3") + assert parent == "gaiadr3" + + parent = CatalogCollection.get_parent_collection("tic_v82") + assert parent == "mast_catalogs" + + @pytest.mark.parametrize("collection", ["classy", "tic_v82"]) + def test_catalog_collection_get_catalogs(self, collection): + cc = CatalogCollection(collection) + catalogs = cc._fetch_catalogs() + assert isinstance(catalogs, Table) + assert len(catalogs) > 0 + assert catalogs.colnames == ["catalog_name", "description"] + + @pytest.mark.parametrize("collection", ["gaiadr3", "ullyses", "tic_v82"]) + def test_catalog_collection_get_catalog_metadata(self, collection): + cc = CatalogCollection(collection) + default_catalog = cc.get_default_catalog() + default_metadata = cc.get_catalog_metadata(default_catalog) + assert isinstance(default_metadata, CatalogMetadata) + assert isinstance(default_metadata.column_metadata, Table) + assert isinstance(default_metadata.ra_column, str) + assert isinstance(default_metadata.dec_column, str) + assert isinstance(default_metadata.supports_spatial_queries, bool) + + assert len(default_metadata.column_metadata) > 1 + assert default_metadata.ra_column in default_metadata.column_metadata["column_name"] + assert default_metadata.dec_column in default_metadata.column_metadata["column_name"] + if collection != "ullyses": + assert default_metadata.supports_spatial_queries + + metadata_cache = cc._catalog_metadata_cache + assert default_catalog.casefold() in metadata_cache + default_metadata_cached = cc.get_catalog_metadata(default_catalog) + assert default_metadata is default_metadata_cached + + def test_catalog_collection_invalid_get_catalog_metadata(self): + cc = CatalogCollection("tic_v82") + invalid_catalog = "invalid_catalog" + with pytest.raises( + InvalidQueryError, match=f"Catalog '{invalid_catalog}' is not recognized for collection '{cc.name}'." + ): + cc.get_catalog_metadata(invalid_catalog) + + @pytest.mark.parametrize("collection", ["ps1_dr2", "classy", "ullyses"]) + def test_catalog_collection_get_default_catalog(self, collection): + cc = CatalogCollection(collection) + catalogs = cc._fetch_catalogs() + default = cc.get_default_catalog() + + assert len(catalogs) > 1 + assert isinstance(catalogs, Table) + assert catalogs.colnames == ["catalog_name", "description"] + + assert not default.startswith("tap_schema") + assert default.casefold() in [name.casefold() for name in catalogs["catalog_name"]] + assert DEFAULT_CATALOGS[collection] == default + + def test_catalog_collection_run_tap_query(self): + cc = CatalogCollection("GAIADR3") + adql_str = ( + "SELECT TOP 10 solution_id, designation, source_id, ra, dec FROM gaia_source WHERE " + "ra BETWEEN 10 AND 11 AND dec BETWEEN 12 AND 13" + ) + result = cc.run_tap_query(adql_str) + assert isinstance(result, Table) - assert os.path.isfile(result['Local Path'][0]) + assert result.colnames == ["solution_id", "designation", "source_id", "ra", "dec"] + assert ((result["ra"] >= 10) & (result["ra"] <= 11)).all() + assert ((result["dec"] >= 12) & (result["dec"] <= 13)).all() + + def test_catalog_collection_verify_criteria(self): + cc = CatalogCollection("tic_v82") + default_catalog = cc.get_default_catalog() + + result = cc._verify_criteria(default_catalog) + assert result is None + + result = cc._verify_criteria(default_catalog, gaiabp=1) + assert result is None + + result = cc._verify_criteria(default_catalog, gaiabp=1, teff=1, e_gaiabp=1) + assert result is None + + close_match_col = "gaiagaiabp" + with pytest.raises( + InvalidQueryError, + match=f"Filter '{close_match_col}' is not recognized for collection " + f"'{cc.name}' and catalog '{default_catalog}'. Did you mean 'gaiabp'?", + ): + cc._verify_criteria(default_catalog, gaiagaiabp=1) + + invalid_col = "fake_column" + with pytest.raises( + InvalidQueryError, + match=f"Filter '{invalid_col}' is not recognized for collection " + f"'{cc.name}' and catalog '{default_catalog}'.", + ): + cc._verify_criteria(default_catalog, fake_column=1) ###################### # TesscutClass tests # diff --git a/docs/mast/mast_catalog.rst b/docs/mast/mast_catalog.rst index fe8c05d857..d2c21c566b 100644 --- a/docs/mast/mast_catalog.rst +++ b/docs/mast/mast_catalog.rst @@ -1,332 +1,530 @@ - *************** Catalog Queries *************** -The Catalogs class provides access to a subset of the astronomical catalogs stored at MAST. -The catalogs currently available through this interface are: +`~astroquery.mast.CatalogsClass` is a versatile tool for discovering and querying the wide range of astronomical catalogs hosted by the +`Mikulski Archive for Space Telescopes (MAST) `_. `~astroquery.mast.CatalogsClass` is a Python wrapper +for our `MAST Table Access Protocol (TAP) Service `_, which allows you to query for catalog +metadata and data. If you were querying the MAST TAP service directly, you would need to write your queries in +`Astronomical Data Query Language (ADQL) `_. With `~astroquery.mast.CatalogsClass`, +you can construct and execute these queries using a more intuitive Python interface, without needing to learn ADQL syntax. + +The catalogs available through MAST are diverse, covering a wide range of astronomical objects and phenomena. +They include data from various missions and surveys, such as the Hubble Space Telescope, Kepler, TESS, Gaia, and many more. +These catalogs are organized into **collections**, each of which may contain one or more catalogs with distinct schemas and capabilities. +The `~astroquery.mast.CatalogsClass` interface is designed for flexible querying of catalog data, including both spatial and non-spatial queries, +as well as the ability to filter results based on specific criteria. + +At a high level, querying MAST catalogs with `~astroquery.mast.CatalogsClass` involves the following steps: + +1. **Discover** available collections and catalogs. +2. **Inspect** catalog metadata to understand available columns and data types, as well as the capabilities of each catalog. +3. **Query** the catalog using spatial and/or non-spatial criteria to retrieve relevant data. + +Collections and Catalogs +======================== -- The Hubble Source Catalog (HSC) -- The GALEX Catalog (V2 and V3) -- The Gaia (DR1 and DR2) and TGAS Catalogs -- The TESS Input Catalog (TIC) -- The TESS Candidate Target List (CTL) -- The Disk Detective Catalog -- The PanSTARRS Catalog (DR1 and DR2) -- The All-Sky PLATO Input Catalog (DR1) +MAST catalogs are organized into **collections**, where each collection represents a set of related catalogs +with a shared scientific or mission context (e.g., Hubble source catalogs, Gaia data releases, etc.). +Within a collection, one or more **catalogs** may be available, each with its own set of columns and capabilities. -Catalog Positional Queries -========================== +`~astroquery.mast.CatalogsClass` stores a ``collection`` and ``catalog`` as attributes. If no collection and/or catalog +is specified in a query, these attributes will be used as defaults. The ``collection`` attribute is an object +representing the current collection, and the ``catalog`` attribute is a string representing the name of the current +catalog within that collection. -Positional queries can be based on a sky position or a target name. -The returned fields vary by catalog, find the field documentation for specific catalogs -`here `__. -If no catalog is specified, the Hubble Source Catalog will be queried. +The default value for the ``collection`` attribute is "hsc", referring to the `Hubble Source Catalog version 3 `_. +The default value for ``catalog`` is "dbo.SumMagAper2CatView". This is a summary source catalog with data describing sources detected in Hubble images, +including their positions, magnitudes, and other properties. - .. doctest-remote-data:: >>> from astroquery.mast import Catalogs ... - >>> catalog_data = Catalogs.query_region("158.47924 -7.30962", catalog="Galex") - >>> print(catalog_data[:10]) # doctest: +IGNORE_OUTPUT - distance_arcmin objID survey ... fuv_flux_aper_7 fuv_artifact - ------------------ ------------------- ------ ... --------------- ------------ - 0.3493802506329695 6382034098673685038 AIS ... 0.047751952 0 - 0.7615422488595471 6382034098672634783 AIS ... -- 0 - 0.9243329366166956 6382034098672634656 AIS ... -- 0 - 1.162615739258038 6382034098672634662 AIS ... -- 0 - 1.2670891287503308 6382034098672634735 AIS ... -- 0 - 1.492173395497916 6382034098674731780 AIS ... 0.0611195639 0 - 1.6051235757244107 6382034098672634645 AIS ... -- 0 - 1.705418541388336 6382034098672634716 AIS ... -- 0 - 1.7463721100195875 6382034098672634619 AIS ... -- 0 - 1.7524423152919317 6382034098672634846 AIS ... -- 0 - - -Some catalogs have a maximum number of results they will return. -If a query results in this maximum number of results a warning will be displayed to alert -the user that they might be getting a subset of the true result set. + >>> print("Default collection:", Catalogs.collection) + Default collection: hsc + >>> print("Default catalog:", Catalogs.catalog) + Default catalog: dbo.SumMagAper2CatView + +These attributes may be changed at any time to set new defaults. Both ``collection`` and ``catalog`` will be validated +when set. When changing the collection, the catalog will be reset to the default for the new collection. + +These attributes can be set with parameters when instantiating a `~astroquery.mast.CatalogsClass` object, or they can be changed +at any time after instantiation to set new defaults for subsequent queries. Both ``collection`` and ``catalog`` will be validated when set. +``collection`` must be a valid collection name, and ``catalog`` must be a valid catalog within the specified collection. When changing the +collection, the catalog will be reset to the default catalog for the new collection. + +Here, we'll change the value of ``collection`` to "ullyses", referring to the +`Hubble Ultraviolet Legacy Library of Young Stars as Essential Standards (ULLYSES) `_ program. The default catalog +for this collection is "sciencemetadata", which contains metadata about the scientific exposures taken as part of the ULLYSES program, +including their coordinates, observation dates, instruments used, and other properties. .. doctest-remote-data:: - >>> from astroquery.mast import Catalogs - ... - >>> catalog_data = Catalogs.query_region("322.49324 12.16683", - ... catalog="HSC", - ... magtype=2) # doctest: +SHOW_WARNINGS - InputWarning: Coordinate string is being interpreted as an ICRS coordinate provided in degrees. - MaxResultsWarning: Maximum catalog results returned, may not include all sources within radius. - >>> print(catalog_data[:10]) - MatchID Distance MatchRA ... W3_F160W_MAD W3_F160W_N - --------- -------------------- ------------------ ... ------------ ---------- - 50180585 0.003984902849540913 322.4931746094701 ... nan 0 - 8150896 0.006357935819940561 322.49334740450234 ... nan 0 - 100906349 0.00808206428937523 322.4932839715549 ... nan 0 - 105434103 0.011947078376104195 322.49324000530777 ... nan 0 - 103116183 0.01274757103013683 322.4934207202404 ... nan 0 - 45593349 0.013026569623011767 322.4933878707698 ... nan 0 - 103700905 0.01306760650244682 322.4932769229944 ... nan 0 - 102470085 0.014611879195009472 322.49311034430366 ... nan 0 - 93722307 0.01476438046135455 322.49348351134466 ... nan 0 - 24781941 0.015234351867433582 322.49300148743345 ... nan 0 - -Radius is an optional parameter and the default is 0.2 degrees. + >>> Catalogs.collection = "ullyses" # set collection to ULLYSES + >>> print("New collection:", Catalogs.collection) + New collection: ullyses + >>> print("New catalog:", Catalogs.catalog) + New catalog: dbo.sciencemetadata + +You can also create multiple instances of `~astroquery.mast.CatalogsClass` with different defaults, which can be useful for working with multiple catalogs +in the same script or notebook. .. doctest-remote-data:: - >>> from astroquery.mast import Catalogs - ... - >>> catalog_data = Catalogs.query_object("M10", radius=.02, catalog="TIC") - >>> print(catalog_data[:10]) # doctest: +IGNORE_OUTPUT - ID ra dec ... wdflag dstArcSec - ---------- ---------------- ----------------- ... ------ ------------------ - 510188144 254.287449269816 -4.09954224264168 ... -1 0.7650443624931581 - 510188143 254.28717785824 -4.09908635292493 ... -1 1.3400566638148848 - 189844423 254.287799703996 -4.0994998249247 ... 0 1.3644407138867785 - 1305764031 254.287147439535 -4.09866105132406 ... -1 2.656905409847388 - 1305763882 254.286696117371 -4.09925522448626 ... -1 2.7561196688252894 - 510188145 254.287431890823 -4.10017293344746 ... -1 3.036238557555728 - 1305763844 254.286675148545 -4.09971617257086 ... 0 3.1424781549696217 - 1305764030 254.287249718516 -4.09841883152995 ... -1 3.365991083435227 - 1305764097 254.287599269103 -4.09837925361712 ... -1 3.4590276863989 - 1305764215 254.28820865799 -4.09859677020253 ... -1 3.7675526728257034 - - -The Hubble Source Catalog, the Gaia Catalog, and the PanSTARRS Catalog have multiple versions. -An optional version parameter allows you to select which version you want, the default is the highest version. + >>> hsc_catalog = Catalogs(collection="hsc") + >>> print("HSC collection:", hsc_catalog.collection) + HSC collection: hsc + >>> print("HSC catalog:", hsc_catalog.catalog) + HSC catalog: dbo.SumMagAper2CatView + >>> + >>> ullyses_catalog = Catalogs(collection="ullyses") + >>> print("ULLYSES collection:", ullyses_catalog.collection) + ULLYSES collection: ullyses + >>> print("ULLYSES catalog:", ullyses_catalog.catalog) + ULLYSES catalog: dbo.sciencemetadata + +Discovering Available Collections and Catalogs +=============================================== + +Discovering Available Collections +---------------------------------- + +To list all of the catalog collections that are accessible via the MAST TAP Service, use the `~astroquery.mast.CatalogsClass.get_collections` method. +This returns an `~astropy.table.Table` containing the names of all available collections. .. doctest-remote-data:: - >>> catalog_data = Catalogs.query_region("158.47924 -7.30962", radius=0.1, - ... catalog="Gaia", version=2) - >>> print("Number of results:",len(catalog_data)) - Number of results: 111 - >>> print(catalog_data[:4]) - solution_id designation ... distance - ------------------- ---------------------------- ... ------------------ - 1635721458409799680 Gaia DR2 3774902350511581696 ... 0.6326770410972467 - 1635721458409799680 Gaia DR2 3774901427093274112 ... 0.8440033390947586 - 1635721458409799680 Gaia DR2 3774902148648277248 ... 0.9199206487344911 - 1635721458409799680 Gaia DR2 3774902453590798208 ... 1.3578181104319944 - -The PanSTARRS Catalog has multiple data releases as well as multiple queryable tables. -An optional data release parameter allows you to select which data release is desired, with the default being the latest version (dr2). -The table to query is a required parameter. + >>> collections = Catalogs.get_collections() + >>> print(collections) # doctest: +IGNORE_OUTPUT + collection_name + --------------- + 3dhst + candels + caom + classy + deepspace + gaiadr3 + goods + hsc + hscv2 + missionmast + ps1_dr2 + ps1dr1 + ps1dr2 + registry + skymapperdr4 + tic + tic_v82 + ullyses + +.. attention:: + Some historical collections are no longer supported for querying and will not appear in this list. If a collection + has been renamed or deprecated, Astroquery will issue a warning and suggest the appropriate replacement where possible. + To query a catalog that is no longer supported, you can install an older version of Astroquery that still supports it. + To install the latest version that supports legacy collections, use the following command: ``pip install astroquery==0.4.12`` + +Discovering Catalogs Within a Collection +----------------------------------------- + +Once a collection is selected, you can discover which catalogs are available within that collection using the +`~astroquery.mast.CatalogsClass.get_catalogs` method. This returns an `~astropy.table.Table` containing names and descriptions of the catalogs +within the currently selected collection. To query catalogs for a specific collection without changing the class state, you can pass the +collection name as an argument to the method. .. doctest-remote-data:: - >>> catalog_data = Catalogs.query_region("158.47924 -7.30962", - ... radius=0.01, - ... catalog="Panstarrs", - ... data_release="dr1", - ... table="mean") - >>> print("Number of results:",len(catalog_data)) # doctest: +IGNORE_OUTPUT - Number of results: 42 - >>> print(catalog_data[:5]) # doctest: +IGNORE_OUTPUT - objName objAltName1 ... yFlags distance - -------------------------- ----------- ... ------ --------------------- - PSO J103357.027-071828.380 -999 ... 0 0.008463641060218161 - PSO J103357.130-071834.314 -999 ... 4120 0.008734008139467626 - PSO J103357.065-071832.617 -999 ... 4120 0.008480972171475138 - PSO J103355.542-071833.037 -999 ... 16416 0.0022151507196657037 - PSO J103356.363-071839.939 -999 ... 0 0.005754569818470991 - -Catalog Criteria Queries -======================== + >>> catalogs = Catalogs.get_catalogs("hsc") + >>> catalogs.pprint(max_width=-1) # doctest: +IGNORE_OUTPUT + catalog_name description + -------------------------- ------------------------------------------------------- + dbo.detailedcatalog Detailed list of source catalog parameters + dbo.hcvdetailedview Detailed list of Hubble Catalog of Variables parameters + dbo.hcvsummaryview Summary list of Hubble Catalog of Variables parameters + dbo.propermotionsview List of proper motion information + dbo.sourcepositionsview List of source position information + dbo.summagaper2catview Summary list of source catalog with Aper2 magnitudes + dbo.summagautocatview Summary list of source catalog with MagAuto magnitudes + dbo.catalog_image_metadata Summary list of Image processing metadata -The TESS Input Catalog (TIC), Disk Detective Catalog, and PanSTARRS Catalog can also be queried based on non-positional criteria. +Inspecting Catalog Metadata +============================ -.. doctest-remote-data:: +Before querying a catalog, it's important to understand what data it contains and how that data is organized. Catalog metadata includes +information about the columns in the catalog (e.g., their names, data types, and descriptions) as well as the capabilities of the catalog +(e.g., what types of queries it supports). This information can help you construct effective queries and interpret the results correctly. - >>> from astroquery.mast import Catalogs - ... - >>> catalog_data = Catalogs.query_criteria(catalog="Tic", Bmag=[30,50], objType="STAR") - >>> print(catalog_data) # doctest: +IGNORE_OUTPUT - ID version HIP TYC ... e_Dec_orig raddflag wdflag objID - --------- -------- --- --- ... ------------------ -------- ------ ---------- - 125413929 20190415 -- -- ... 0.293682765259495 1 0 579825059 - 261459129 20190415 -- -- ... 0.200397148604244 1 0 1701625107 - 64575709 20190415 -- -- ... 0.21969663115091 1 0 595775997 - 94322581 20190415 -- -- ... 0.205286802302475 1 0 606092549 - 125414201 20190415 -- -- ... 0.22398993783274 1 0 579825329 - 463721073 20190415 -- -- ... 0.489828592248652 -1 1 710312391 - 81609218 20190415 -- -- ... 0.146788572369267 1 0 630541794 - 282024596 20190415 -- -- ... 0.548806522539047 1 0 573765450 - 23868624 20190415 -- -- ... 355.949 -- 0 916384285 - 282391528 20190415 -- -- ... 0.47766300834538 0 0 574723760 - 123585000 20190415 -- -- ... 0.618316068787371 0 0 574511442 - 260216294 20190415 -- -- ... 0.187170498094167 1 0 683390717 - 406300991 20190415 -- -- ... 0.0518318978617112 0 0 1411465651 +Inspecting Catalog Columns +--------------------------- + +Use the `~astroquery.mast.CatalogsClass.get_column_metadata` method to inspect the columns of a catalog. This returns an `~astropy.table.Table` +with information about each column, including its name, data type, unit, and a description. This metadata is crucial for constructing valid queries, +selecting columns of interest, and understanding which columns support different criteria syntax. +Again, you can specify the collection and catalog explicitly as inputs to the function, or you can rely on the default values stored in the class attributes. +If you only specify a collection, the default catalog for that collection will be used. If you only specify a catalog, the current collection will be used. .. doctest-remote-data:: - >>> from astroquery.mast import Catalogs - ... - >>> catalog_data = Catalogs.query_criteria(catalog="Ctl", - ... object_name='M101', - ... radius=1, - ... Tmag=[10.75,11]) - >>> print(catalog_data) - ID version HIP TYC ... raddflag wdflag objID - --------- -------- --- ------------ ... -------- ------ --------- - 233458861 20190415 -- 3852-01407-1 ... 1 0 150390757 - 441662028 20190415 -- 3855-00941-1 ... 1 0 150395533 - 441658008 20190415 -- 3852-00116-1 ... 1 0 150246361 - 441639577 20190415 -- 3852-00429-1 ... 1 0 150070672 - 441658179 20190415 -- 3855-00816-1 ... 1 0 150246482 - 154258521 20190415 -- 3852-01403-1 ... 1 0 150281963 - 441659970 20190415 -- 3852-00505-1 ... 1 0 150296707 - 441660006 20190415 -- 3852-00341-1 ... 1 0 150296738 + >>> catalog_metadata = Catalogs.get_column_metadata(collection="ullyses", catalog="dbo.sciencemetadata") + >>> catalog_metadata[:5].pprint(max_width=-1) + column_name datatype unit ucd description + --------------------- -------- ---- -------------------- ----------------------------------- + target_id int meta.id;meta.main ullyses target id + target_name_ullyses char meta.id.assoc ullyses name of target + target_name_simbad char meta.id.assoc simbad name of target + target_name_hlsp char meta.id.assoc hlsp name of target + target_classification char src.class.stargalaxy target type: lmc; smc; t tau; low z + +Inspecting Catalog Capabilities +------------------------------- +Each catalog has different capabilities, which are important to understand when constructing your queries. For example, only certain catalogs +support spatial queries based on a sky position or region. Use the `~astroquery.mast.CatalogsClass.supports_spatial_queries()` method to check +if a catalog supports spatial queries. .. doctest-remote-data:: - >>> from astroquery.mast import Catalogs - ... - >>> catalog_data = Catalogs.query_criteria(catalog="DiskDetective", - ... object_name="M10", - ... radius=2, - ... state="complete") - >>> print(catalog_data) # doctest: +IGNORE_OUTPUT - designation ... ZooniverseURL - ------------------- ... ---------------------------------------------------- - J165628.40-054630.8 ... https://talk.diskdetective.org/#/subjects/AWI0005cka - J165748.96-054915.4 ... https://talk.diskdetective.org/#/subjects/AWI0005ckd - J165427.11-022700.4 ... https://talk.diskdetective.org/#/subjects/AWI0005ck5 - J165749.79-040315.1 ... https://talk.diskdetective.org/#/subjects/AWI0005cke - J165327.01-042546.2 ... https://talk.diskdetective.org/#/subjects/AWI0005ck3 - J165949.90-054300.7 ... https://talk.diskdetective.org/#/subjects/AWI0005ckk - J170314.11-035210.4 ... https://talk.diskdetective.org/#/subjects/AWI0005ckv - - -The `~astroquery.mast.CatalogsClass.query_criteria` function requires at least one non-positional parameter. -These parameters are the column names listed in the `field descriptions `__ -of the catalog being queried. They do not include object_name, coordinates, or radius. Running a query with only positional -parameters will result in an error. + >>> supports_spatial_ullyses = Catalogs.supports_spatial_queries(collection="ullyses", catalog="dbo.sciencemetadata") + >>> print("Supports spatial queries:", supports_spatial_ullyses) + Supports spatial queries: False + >>> + >>> supports_spatial_hsc = Catalogs.supports_spatial_queries(collection="hsc", catalog="dbo.SumMagAper2CatView") + >>> print("Supports spatial queries:", supports_spatial_hsc) + Supports spatial queries: True + +Querying Catalogs +================== + +`~astroquery.mast.CatalogsClass` provides three main methods for querying catalogs: + +- `~astroquery.mast.CatalogsClass.query_criteria` is the most flexible method. It supports purely column-based queries, purely spatial queries, or a combination of both. +- `~astroquery.mast.CatalogsClass.query_region` is a convenience method for spatial queries that use coordinates or a region on the sky. +- `~astroquery.mast.CatalogsClass.query_object` is a convenience method for spatial queries that use an object name resolved to coordinates. + +All three methods ultimately construct and execute an ADQL query against the MAST TAP service. All three support column-based filtering, sorting, and +limiting of results. The primary difference between them is whether and how spatial criteria are specified. + +Shared Query Parameters +------------------------ + +The following parameters are shared across all three query methods: + +- ``collection`` : The name of the catalog collection to query. If not specified, the ``collection`` class attribute will be used as the default. +- ``collection``: The name of the catalog collection to query. If not specified, the ``collection`` class attribute will be used as the default. +- ``catalog``: The name of the catalog to query within the specified collection. If not specified, the ``catalog`` class attribute will be used as the default. +- ``limit``: An integer specifying the maximum number of results to return. The default is 5,000. +- ``offset``: An integer specifying the number of results to skip before starting to return results. This is useful for paginating through large result sets. Default is 0. +- ``count_only``: A boolean indicating whether to return only the count of matching results instead of the results themselves. Default is False. +- ``select_cols``: A list of column names to include in the results. If not specified, all columns will be returned. +- ``sort_by``: A string or list of strings specifying the column(s) to sort the results by. Default is None (no sorting). +- ``sort_desc``: A boolean or list of booleans specifying whether to sort in descending order for each column specified in ``sort_by``. + Default is False (ascending order). +- ``filters``: Another parameter used to specify criteria filters as a dictionary. Use this option when the name of a column conflicts + with a named parameter of this method. +- ``run_async``: If True, run the query in asynchronous mode. This mode is more robust and preferable for long-running queries. +- ``return_adql``: If True, return the ADQL query string instead of executing the query. This is useful for debugging or for users who want + to run the query directly against the TAP service. When False, the ADQL query string is also returned in the metadata of the result table. + +These parameters allow users to control the scope and format of their queries consistently across all three methods. + +Criteria Syntax +---------------- + +All query methods also allow you to filter results based on column values. Users may specify criteria using keyword arguments, where the keyword +is the column name and the value is the filter condition. Multiple criteria are combined using a logical **AND**. + +Criteria syntax supports a variety of operations for filtering results: + +- A single value (e.g. ``column=value``) will filter for rows where the column is equal to the value. +- A list of values (e.g. ``column=[value1, value2]``) will filter for rows where the column is equal to any of the values in the list (logical OR). +- A value prefixed with ``!`` (e.g. ``column="!value"``) will filter for rows where the column is not equal to the value. +- For string columns, a string with a wildcard character ``*`` (e.g. ``column="NGC*"``) will filter for rows where the column value matches the pattern, + where ``*`` can match any sequence of characters. +- For numeric columns, a string with a comparison operator (e.g. ``column=">value"``) will filter for rows where the column value satisfies the specified + comparison. Supported operators include ``<``, ``>``, ``<=``, and ``>=``. +- For numeric columns, a string with a range (e.g. ``column="value1..value2"``) will filter for rows where the column value falls within the specified range + (inclusive). +- For temporal columns, values can be specified as strings in a recognized date/time format (e.g. ``YYYY``, ``YYYY-MM-DD``, ``YYYY-MM-DD hh:mm:ss``, etc.), + ``astropy.time.Time`` objects, or ``datetime`` objects. The same comparison operators and range syntax as numeric columns can be used to filter temporal + columns based on date/time values. + +We'll use the Ullyses science metadata catalog to demonstrate a column-based query, since it doesn't support spatial queries. Let's filter for the following: + +- Targets with a name that starts with "NGC". +- Targets that belong to spectral class "O" or "B". +- Targets that are NOT known binaries. +- Targets that are NOT classified as "Galaxy" or "Late O Dwarf". +- Targets with Gaia parallax less than -0.01 or greater than or equal to 0. +- Targets with effective temperature between 30,000 and 50,000 K. + +We will also select a subset of columns to return with the ``select_cols`` parameter. .. doctest-remote-data:: - >>> from astroquery.mast import Catalogs - ... - >>> catalog_data = Catalogs.query_criteria(catalog="Tic", - ... object_name='M101', radius=1) - Traceback (most recent call last): - ... - astroquery.exceptions.InvalidQueryError: At least one non-positional criterion must be supplied. - + >>> result = Catalogs.query_criteria( + ... collection="ullyses", # Query the 'ullyses' collection + ... catalog="sciencemetadata", # Query the 'sciencemetadata' catalog + ... target_name_ullyses="NGC*", # Query for targets names starting with 'NGC' + ... sp_class=["O", "B"], # Query for targets with spectral class "O" or "B" + ... known_binary=False, # Query for targets that are not known binaries + ... target_classification=["!Galaxy", "!Late O Dwarf"], # Exclude targets classified as 'Galaxy' or 'Late O Dwarf' + ... gaia_parallax=["<-0.1", ">=0"], # Query for targets with Gaia parallax less than -0.01 or greater than or equal to 0 + ... star_teff="30000..50000", # Query for targets with effective temperature between 30,000 and 50,000 K + ... select_cols=["target_name_ullyses", "target_classification", "known_binary", "sp_class", "gaia_parallax", "star_teff"] + ... ) + >>> result[:5].pprint(max_width=-1) #doctest: +IGNORE_OUTPUT + target_name_ullyses target_classification known_binary sp_class gaia_parallax star_teff + mas K + ------------------- --------------------- ------------ -------- ------------- --------- + NGC346 ELS 043 Early B Dwarf False B -0.111579 33000.0 + NGC346 ELS 026 Early B Subgiant False O -0.047347 31000.0 + NGC346 ELS 028 Mid O Dwarf False O -0.069206 39600.0 + NGC346 ELS 007 Early O Dwarf False O -0.070696 42100.0 + NGC346 MPG 356 Mid O Dwarf False O -0.051241 38200.0 + +Spatial Query Parameters +------------------------ + +If a catalog supports spatial queries, the following parameters can be used to specify the spatial region of interest: + +- ``coordinates``: A string or `~astropy.coordinates.SkyCoord` object specifying the center of a cone search. This parameter is used in + conjunction with ``radius``. +- ``object_name``: A string specifying the name of an astronomical object to resolve to coordinates for a cone search. This parameter is used in + conjunction with ``radius``. +- ``resolver``: A string specifying the name of the resolver to use when resolving ``object_name`` to coordinates. This is only applicable when + ``object_name`` is provided. Default is None. +- ``radius``: The radius of a cone search around ``coordinates`` or ``object_name``. Can be defined as a string with units (e.g., "10 arcsec"), + a `~astropy.units.Quantity`, or a float in degrees. Default is 0.2 degrees. +- ``region``: Specifies the spatial region of interest for more complex spatial queries, such as polygon searches. + Please see the `Specifying Spatial Regions`_ section below for details on how to use this parameter. + +If no spatial parameters are provided, the query is purely column-based and will not filter results based on position. If they are supplied, the +spatial parameters are combined with any column-based criteria using a logical **AND**, meaning that only results that satisfy both the spatial and +column-based criteria will be returned. + +We'll demonstrate a spatial query using the HSC summary source catalog, which supports spatial queries. Let's filter for the following: + +- Sources within 2 arcseconds of the coordinates (322.49324, 12.16683). +- Sources with target names that are either "M-15" or start with "NGC". +- Sources with a start time between 2006 and 2013. + +The query will sort results first by the number of images in ascending order, and then by start time in descending order. We'll also limit +the number of results returned to 10 and select a subset of columns to return with the ``select_cols`` parameter. -The PanSTARRS catalog also accepts additional parameters to allow for query refinement. These options include column selection, -sorting, column criteria, page size and page number. Additional information on PanSTARRS queries may be found -`here `__. +.. doctest-remote-data:: -Columns returned from the query may be submitted with the columns parameter as a list of column names. + >>> result = Catalogs.query_criteria( + ... collection="hsc", + ... coordinates="322.49324 12.16683", + ... radius="2 arcsec", # Query for sources within 2 arcseconds of the specified coordinates + ... targetname=["M-15", "NGC*"], # Query for targets with names 'M-15' or starting with 'NGC' + ... starttime="2006..2013", # Query for observations with starttime between 2006 and 2013 + ... sort_by=["numimages", "starttime"], # Sort results by number of images and then by starttime + ... sort_desc=[False, True], # Sort numimages in ascending order and starttime in descending order + ... limit=5, # Limit to 5 results + ... select_cols=["matchid", "matchra", "matchdec", "numimages", "starttime", "targetname"] + ... ) + >>> result.pprint(max_width=-1) + matchid matchra matchdec numimages starttime targetname + deg deg + --------- ------------------ ------------------ --------- -------------------------- ---------- + 61895629 322.493715383149 12.166629788750484 1 2011-10-22 08:10:21.217000 M-15 + 11562863 322.49294957070185 12.166668540816076 2 2006-05-02 01:13:43.920000 NGC7078 + 16381110 322.4936372300021 12.166722963370844 3 2011-10-07 15:20:59.197000 M-15 + 105452327 322.4933282596331 12.16732046125442 3 2011-10-07 15:20:59.197000 M-15 + 49726591 322.4927927121447 12.166998250407733 3 2006-05-02 01:13:43.920000 NGC7078 + +The `~astroquery.mast.CatalogsClass.query_region` and `~astroquery.mast.CatalogsClass.query_object` methods are convenience methods for spatial queries. +`~astroquery.mast.CatalogsClass.query_region` allows you to specify a region on the sky using the ``coordinates``, ``radius``, and/or ``region`` parameters. +`~astroquery.mast.CatalogsClass.query_object` allows you to specify an ``object_name`` that will be resolved to coordinates and a ``radius`` for a cone search. +Both methods also support column-based criteria, sorting, and limiting of results, just like `~astroquery.mast.CatalogsClass.query_criteria`. + +For these queries, we will use the ``tic_v82`` collection, which refers to the +`TESS Input Catalog version 8.2 `_. We'll use the `~astroquery.mast.CatalogsClass.query_region` +method to perform a simple cone search for sources within 1 arcminute of the coordinates (158.47924, -7.30962). -The query may be sorted with the sort_by parameter composed of either a single column name (to sort ascending), -or a list of multiple column names and/or tuples of direction and column name (ASC/DESC, column name). +.. doctest-remote-data:: -To filter the query, criteria per column name are accepted. The 'AND' operation is performed between all -column name criteria, and the 'OR' operation is performed within column name criteria. Per each column name -parameter, criteria may consist of either a value or a list. The list may consist of a mix of values and -tuples of criteria decorator (min, gte, gt, max, lte, lt, like, contains) and value. + >>> result = Catalogs.query_region( + ... collection="tic_v82", + ... catalog="source", + ... coordinates="158.47924 -7.30962", + ... radius="1 arcmin", + ... select_cols=["id", "ra", "dec"] + ... ) + >>> result.pprint(max_width=-1) #doctest: +IGNORE_OUTPUT + id ra dec + deg deg + --------- ---------------- ----------------- + 841736281 158.483019303286 -7.32320013067735 + 56661355 158.467833401313 -7.31994230664877 + 841736289 158.475246467012 -7.29984176473098 + +For this next query, we'll use the `~astroquery.mast.CatalogsClass.query_object` method to search for sources within 0.1 degrees of the object +`M11 `_, which is an open star cluster +also known as the Wild Duck Cluster. We'll also filter for sources that are stars, sort results by the effective temperature of the source, and +limit the number of results to 10. .. doctest-remote-data:: - >>> catalog_data = Catalogs.query_criteria(coordinates="5.97754 32.53617", - ... radius=0.01, - ... catalog="PANSTARRS", - ... table="mean", - ... data_release="dr2", - ... nStackDetections=[("gte", 2)], - ... columns=["objName", "objID", "nStackDetections", "distance"], - ... sort_by=[("desc", "distance")], - ... pagesize=15) - >>> print(catalog_data[:10]) # doctest: +IGNORE_OUTPUT - objName objID nStackDetections distance - --------------------- ------------------ ---------------- --------------------- - PSO J005.9812+32.5270 147030059812483022 5 0.009651200148871086 - PSO J005.9726+32.5278 147030059727583992 2 0.0093857181370567 - PSO J005.9787+32.5453 147050059787164914 4 0.009179045509852305 - PSO J005.9722+32.5418 147050059721440704 4 0.007171813230776031 - PSO J005.9857+32.5377 147040059855825725 4 0.007058815429178634 - PSO J005.9810+32.5424 147050059809651427 2 0.006835678269917365 - PSO J005.9697+32.5368 147040059697224794 2 0.006654002479439699 - PSO J005.9712+32.5330 147040059711340087 4 0.006212461367287632 - PSO J005.9747+32.5413 147050059747400181 5 0.0056515210592035965 - PSO J005.9775+32.5314 147030059774678271 3 0.004739286624336443 - - -Hubble Source Catalog (HSC) specific queries -============================================ - -Given an HSC Match ID, return all catalog results. + >>> result = Catalogs.query_object( + ... collection="tic_v82", + ... object_name="M11", + ... radius=0.1, + ... objtype="STAR", + ... sort_by="teff", + ... select_cols=["id", "ra", "dec", "objtype", "teff"], + ... limit=10 + ... ) + >>> result.pprint(max_width=-1) + id ra dec objtype teff + deg deg K + --------- ---------------- ----------------- ------- ------ + 151449173 282.67863187603 -6.26184813416028 STAR 3025.0 + 151456872 282.798899858481 -6.3102827609116 STAR 3093.0 + 151455613 282.743580903863 -6.2188868480077 STAR 3260.0 + 31821653 282.851911590723 -6.26914233254122 STAR 3265.0 + 151456960 282.776343005483 -6.31830783055104 STAR 3281.0 + 151457177 282.777039432654 -6.34079199389906 STAR 3285.0 + 151457138 282.762562964854 -6.33622888690153 STAR 3290.0 + 151455292 282.769687283022 -6.18834508004063 STAR 3311.0 + 151455728 282.730778111903 -6.2287837491146 STAR 3325.0 + 151456312 282.737426625799 -6.26956499623787 STAR 3372.0 + +.. _specifying-spatial-regions: + +Specifying Spatial Regions +-------------------------- + +For catalogs that support spatial queries, there are several ways to specify the spatial region of interest. The simplest is a cone search, +defined by a center position and a radius. More complex regions, such as polygons, can also be specified using the ``region`` parameter. + +Cone Search +^^^^^^^^^^^ + +Cone searches are the most common type of spatial query, defined by a center position and a radius. They may be specified using: + +- The ``coordinates`` and ``radius`` parameters together. +- The ``object_name`` and ``radius`` parameters together, where the object name is resolved to coordinates. +- The ``region`` parameter as: + - A `~regions.CircleSkyRegion` object from the `~regions` package. + - A Space-Time Coordinate (STC) string in the format ``CIRCLE [frame] ``, where ra, dec, and radius are in degrees. + +Let's demonstrate this on the ``skymapperdr4`` collection, which contains catalogs from the +`SkyMapper Southern Survey: Data Release 4 `_. The catalog we query will be "dr4.master", which is +a master catalog of sources detected in the SkyMapper DR4 survey, containing their positions, magnitudes, and other properties. Our cone +search will be centered on the coordinate (18.855, -6.945) with a radius of 0.01 degrees. .. doctest-remote-data:: + >>> from regions import CircleSkyRegion + >>> from astropy.coordinates import SkyCoord + >>> import astropy.units as u + >>> + >>> circle_sky_region = CircleSkyRegion(center=SkyCoord(18.86, -6.95, unit='deg'), radius=0.01*u.deg) + >>> circle_stc_string = "CIRCLE ICRS 18.855 -6.945 0.01" + >>> + >>> result = Catalogs.query_region( + ... collection="skymapperdr4", + ... catalog="dr4.master", + ... region=circle_sky_region, # or use region=circle_stc_string + ... select_cols=["object_id", "raj2000", "dej2000"] + ... ) + >>> result.pprint(max_width=-1) + object_id raj2000 dej2000 + deg deg + ---------- --------- --------- + 2025217836 18.858273 -6.955493 + 2025218117 18.867198 -6.950921 + 2025218116 18.868135 -6.952049 + 2025217835 18.861348 -6.958706 + 1018207669 18.862164 -6.959528 + +Polygon Search +^^^^^^^^^^^^^^^ + +Polygon searches allow for more complex spatial queries by defining a polygonal region on the sky. +They may be specified using any of the following as the ``region`` parameter: + +- An iterable of (RA, Dec) tuples representing the vertices of the polygon. +- A `~regions.PolygonSkyRegion` object from the `~regions` package. +- A Space-Time Coordinate (STC) string in the format ``POLYGON [frame] ... ``, where (ra_i, dec_i) + are the vertices of the polygon in degrees. + +Keep in mind that at least three vertices are required to define a valid polygon, and the vertices should be ordered either clockwise or +counterclockwise. The polygon will be automatically closed by connecting the last vertex back to the first. + +Let's search for sources in the Skymapper source catalog that fall within a four-sided polygon. The polygon has the same center as the +previous cone search, so the results may look similar! - >>> from astroquery.mast import Catalogs - ... - >>> catalog_data = Catalogs.query_object("M10", - ... radius=.001, - ... catalog="HSC", - ... magtype=1) - >>> matchid = catalog_data[0]["MatchID"] - >>> print(matchid) - 7542452 - >>> matches = Catalogs.query_hsc_matchid(matchid) - >>> print(matches) - CatID MatchID ... cd_matrix - --------- ------- ... ------------------------------------------------------ - 419094794 7542452 ... -1.10056e-005 5.65193e-010 5.65193e-010 1.10056e-005 - 419094795 7542452 ... -1.10056e-005 5.65193e-010 5.65193e-010 1.10056e-005 - 401289578 7542452 ... -1.10056e-005 1.56577e-009 1.56577e-009 1.10056e-005 - 401289577 7542452 ... -1.10056e-005 1.56577e-009 1.56577e-009 1.10056e-005 - 257194049 7542452 ... -1.38889e-005 -5.26157e-010 -5.26157e-010 1.38889e-005 - 257438887 7542452 ... -1.38889e-005 -5.26157e-010 -5.26157e-010 1.38889e-005 - - -HSC spectra accessed through this class as well. `~astroquery.mast.CatalogsClass.get_hsc_spectra` -does not take any arguments, and simply loads all HSC spectra. +.. doctest-remote-data:: + >>> from regions import PolygonSkyRegion + >>> + >>> polygon_iter = [(18.85, -6.96), (18.87, -6.96), (18.87, -6.94), (18.85, -6.94)] + >>> polygon_sky_region = PolygonSkyRegion(vertices=SkyCoord( + ... [[18.85, -6.96], [18.87, -6.96], [18.87, -6.94], [18.85, -6.94]], + ... unit='deg' + ... )) + >>> polygon_stc_string = "POLYGON ICRS 18.85 -6.96 18.87 -6.96 18.87 -6.94 18.85 -6.94" + >>> + >>> result = Catalogs.query_region( + ... collection="skymapperdr4", + ... catalog="dr4.master", + ... region=polygon_sky_region, # or use region=polygon_sky_region or region=polygon_stc_string + ... select_cols=["object_id", "raj2000", "dej2000"] + ... ) + >>> result.pprint(max_width=-1) + object_id raj2000 dej2000 + deg deg + ---------- --------- --------- + 2025217836 18.858273 -6.955493 + 2025218117 18.867198 -6.950921 + 2025218116 18.868135 -6.952049 + 2025217835 18.861348 -6.958706 + 1018207669 18.862164 -6.959528 + 13693185 18.853648 -6.941675 + 2025218118 18.851606 -6.942183 + 1018207883 18.851708 -6.941662 + + +Counting Results +----------------- + +Each of the three query methods supports a ``count_only`` parameter. When set to ``True``, it returns only the number of matching results. +This can be useful for quickly assessing the size of a result set without having to retrieve all the data. + +Let's demonstrate this on the ``caom`` collection, which refers to the +`Common Archive Observation Model `_, a data model used to +describe astronomical observations. We'll query the ``obspointing`` catalog, which contains metadata about scientific observations at MAST. +We'll perform a query to count the number of observations that are within 0.2 degrees of the exoplanet +`WASP-12b `_. You'll notice that this query takes several seconds to complete. +The ``obspointing`` catalog is huge, and if you were to run the same query without the ``count_only`` parameter, it would typically take +longer to return the full results, especially if there are many matching sources. + +For very long-running queries, you can also run the query in asynchronous mode by setting ``run_async=True``. This will prevent the query from timing out. .. doctest-remote-data:: - >>> from astroquery.mast import Catalogs - ... - >>> all_spectra = Catalogs.get_hsc_spectra() - >>> print(all_spectra[:10]) - ObjID DatasetName MatchID ... PropID HSCMatch - ----- -------------------------------------------- -------- ... ------ -------- - 20010 HAG_J072655.67+691648.9_J8HPAXAEQ_V01.SPEC1D 19657846 ... 9482 Y - 20011 HAG_J072655.69+691648.9_J8HPAOZMQ_V01.SPEC1D 19657846 ... 9482 Y - 20012 HAG_J072655.76+691729.7_J8HPAOZMQ_V01.SPEC1D 19659745 ... 9482 Y - 20013 HAG_J072655.82+691620.0_J8HPAOZMQ_V01.SPEC1D 19659417 ... 9482 Y - 20014 HAG_J072656.34+691704.7_J8HPAXAEQ_V01.SPEC1D 19660230 ... 9482 Y - 20015 HAG_J072656.36+691704.7_J8HPAOZMQ_V01.SPEC1D 19660230 ... 9482 Y - 20016 HAG_J072656.36+691744.9_J8HPAOZMQ_V01.SPEC1D 19658847 ... 9482 Y - 20017 HAG_J072656.37+691630.2_J8HPAXAEQ_V01.SPEC1D 19660827 ... 9482 Y - 20018 HAG_J072656.39+691630.2_J8HPAOZMQ_V01.SPEC1D 19660827 ... 9482 Y - 20019 HAG_J072656.41+691734.9_J8HPAOZMQ_V01.SPEC1D 19656620 ... 9482 Y - - -Individual or ranges of spectra can be downloaded using the -`~astroquery.mast.CatalogsClass.download_hsc_spectra` function. + >>> count = Catalogs.query_criteria( + ... collection="caom", + ... catalog="obspointing", + ... object_name="WASP-12b", + ... radius=0.2, + ... count_only=True, + ... run_async=True + ... ) + >>> print('Number of matching records:', count) + Number of matching records: 6735 -.. doctest-remote-data:: +Deprecated Interfaces +====================== - >>> from astroquery.mast import Catalogs - ... - >>> all_spectra = Catalogs.get_hsc_spectra() - >>> manifest = Catalogs.download_hsc_spectra(all_spectra[100:104]) # doctest: +IGNORE_OUTPUT - Downloading URL https://hla.stsci.edu/cgi-bin/ecfproxy?file_id=HAG_J072704.61+691530.3_J8HPAOZMQ_V01.SPEC1D.fits to ./mastDownload/HSC/HAG_J072704.61+691530.3_J8HPAOZMQ_V01.SPEC1D.fits ... [Done] - Downloading URL https://hla.stsci.edu/cgi-bin/ecfproxy?file_id=HAG_J072704.68+691535.9_J8HPAOZMQ_V01.SPEC1D.fits to ./mastDownload/HSC/HAG_J072704.68+691535.9_J8HPAOZMQ_V01.SPEC1D.fits ... [Done] - Downloading URL https://hla.stsci.edu/cgi-bin/ecfproxy?file_id=HAG_J072704.70+691530.2_J8HPAOZMQ_V01.SPEC1D.fits to ./mastDownload/HSC/HAG_J072704.70+691530.2_J8HPAOZMQ_V01.SPEC1D.fits ... [Done] - Downloading URL https://hla.stsci.edu/cgi-bin/ecfproxy?file_id=HAG_J072704.73+691808.0_J8HPAOZMQ_V01.SPEC1D.fits to ./mastDownload/HSC/HAG_J072704.73+691808.0_J8HPAOZMQ_V01.SPEC1D.fits ... [Done] - ... - >>> print(manifest) # doctest: +IGNORE_OUTPUT - Local Path ... URL - -------------------------------------------------------------------- ... ---- - ./mastDownload/HSC/HAG_J072704.61+691530.3_J8HPAOZMQ_V01.SPEC1D.fits ... None - ./mastDownload/HSC/HAG_J072704.68+691535.9_J8HPAOZMQ_V01.SPEC1D.fits ... None - ./mastDownload/HSC/HAG_J072704.70+691530.2_J8HPAOZMQ_V01.SPEC1D.fits ... None - ./mastDownload/HSC/HAG_J072704.73+691808.0_J8HPAOZMQ_V01.SPEC1D.fits ... None \ No newline at end of file +Several legacy methods related to the Hubble Source Catalog (HSC) remain available but are deprecated and will be removed +in a future release. These methods include: + +- `~astroquery.mast.CatalogsClass.query_hsc_matchid` +- `~astroquery.mast.CatalogsClass.get_hsc_spectra` +- `~astroquery.mast.CatalogsClass.download_hsc_spectra` + +New workflows should use the general `~astroquery.mast.CatalogsClass` interface described above.