Source code for wwt_api_client.constellations

# Copyright 2023 the .NET Foundation
# Distributed under the MIT license

"""
Interacting with the WWT Constellations APIs.

"Constellations" (sometimes abbreviated "CX") is a modernized WWT web service
with a social-media-style interface. It includes an identity layer based on
`OpenID Connect`_ that Python-based tools can use to authenticate to the
Constellations APIs.

.. _OpenID Connect: https://openid.net/connect/

The client can connect to different instances of the backend API and
authentication service. By default, it connects to the production environment.
To make it so that your code can choose which version to use on-the-fly, use the
default constructors and set the environment variable ``NUXT_PUBLIC_API_URL``.
You'll probably wish to use one of the following values:

- ``https://api.worldwidetelescope.org`` for the production API
- ``http://localhost:7000`` for a standard local testing environment
"""

from dataclasses import dataclass
from dataclasses_json import dataclass_json
import os
from requests import RequestException, Response
from typing import List, Optional

from openidc_client import OpenIDCClient

from .data import ImageSummary, SceneHydrated, _strip_nulls_in_place

__all__ = """
ClientConfig
CxClient
""".split()


[docs] @dataclass class ClientConfig: """ Configuration settings for a WWT Constellations client. These influence which instance of the API the client actually connects to. """ id_provider_url: str client_id: str api_url: str
[docs] @classmethod def new_default(cls) -> "ClientConfig": """ Create a new client configuration with sensible default settings. This method defaults to using the public, production WWT Constellations service. To override the backend, set ``NUXT_PUBLIC_API_URL`` to something else, such as ``http://localhost:7000`` for the default local testing configuration. The "sensible default" settings are determined in the following way: - If the environment variable ``NUXT_PUBLIC_API_URL`` is set, its value used as the base URL for all API calls. (The name of this variable aligns with the one used by the Constellations frontend server.) - Otherwise, ``https://api.worldwidetelescope.org`` is used. - If the environment variable ``NUXT_PUBLIC_KEYCLOAK_URL`` is set, its value used as the base URL for the authentication service. - Otherwise, if the environment variable ``KEYCLOAK_URL`` is set, its value is used. - Otherwise, if the base API URL contains the string ``localhost``, the value ``http://localhost:8080`` is used. This is the default used by the standard Keycloak Docker image. - Otherwise, if the base API URL contains the string ``worldwidetelescope.org``, the value ``https://worldwidetelescope.org/auth/`` is used. This is the setting for the WWT Constellations production environment. - Otherwise, if the base API URL contains the string ``wwtelescope.dev``, the value ``https://wwtelescope.dev/auth/`` is used. This is the setting for the WWT Constellations development environment. - Otherwise, an error is raised - The base API URL is normalized to *not* end in a slash - The base authentication URL is normalized *to* end in a slash; then the text ``realms/constellations`` is appended. - Finally, if the environment variable ``WWT_API_CLIENT_ID`` is set, its value is used to set the client ID. - Otherwise it defaults to ``automation``. """ api_url = os.environ.get("NUXT_PUBLIC_API_URL") client_id = os.environ.get("WWT_API_CLIENT_ID", "automation") default_id_base = "https://worldwidetelescope.org/auth/" if api_url is not None: if "localhost" in api_url: # localhost mode? default_id_base = "http://localhost:8080/" elif "wwtelescope.dev" in api_url: # dev mode? default_id_base = "https://wwtelescope.dev/auth/" else: api_url = "https://api.worldwidetelescope.org" if api_url.endswith("/"): api_url = api_url[:-1] id_base = os.environ.get("NUXT_PUBLIC_KEYCLOAK_URL") if id_base is None: id_base = os.environ.get("KEYCLOAK_URL", default_id_base) if id_base is None: raise Exception( "unable to infer the WWT Constellations Keycloak URL; set the environment variable NUXT_PUBLIC_KEYCLOAK_URL" ) if not id_base.endswith("/"): id_base += "/" return cls( id_provider_url=id_base + "realms/constellations", client_id=client_id, api_url=api_url, )
[docs] @classmethod def new_prod(cls) -> "ClientConfig": """ Create a new client configuration explicitly set up for the WWT Constellations production environment. You should probably use :meth:`new_default` unless you explicitly want your code to *always* refer to the production environment. """ return cls( id_provider_url="https://worldwidetelescope.org/auth/realms/constellations", client_id="automation", api_url="https://api.worldwidetelescope.org", )
[docs] @classmethod def new_dev(cls) -> "ClientConfig": """ Create a new client configuration explicitly set up for the WWT Constellations development environment. You should probably use :meth:`new_default` unless you explicitly want your code to *always* refer to the development environment. """ return cls( id_provider_url="https://wwtelescope.dev/auth/realms/constellations", client_id="cli-tool", api_url="https://api.wwtelescope.dev", )
@dataclass_json(undefined="EXCLUDE") @dataclass class FindImagesByLegacyRequest: wwt_legacy_url: str @dataclass_json(undefined="EXCLUDE") @dataclass class FindImagesByLegacyResponse: results: List[ImageSummary] @dataclass_json(undefined="EXCLUDE") @dataclass class TimelineResponse: results: List[SceneHydrated] BuiltinBackgroundsResponse = FindImagesByLegacyResponse # I think this is unlikely to ever need to be configurable? _ID_PROVIDER_MAPPING = { "Authorization": "/protocol/openid-connect/auth", "Token": "/protocol/openid-connect/token", }
[docs] class CxClient: """ A client for the WWT Constellations APIs. This client authenticates automatically using OpenID Connect protocols. API calls may cause it to print a URL to the terminal, requesting that the user visit it to navigate a login flow. Parameters ---------- config : optional :class:`ClientConfig` If specified, the client configuration to use. Defaults to calling :meth:`ClientConfig.new_default`. oidcc_cache_identifier: optional :class:`str` The identifier to use for caching this client's state in the ``openidc_client`` cache. Defaults to ``"wwt_api_client"``. You are unlikely to need to change this setting. """ _config: ClientConfig _oidcc: OpenIDCClient def __init__( self, config: Optional[ClientConfig] = None, oidcc_cache_identifier: Optional[str] = "wwt_api_client", ): if config is None: config = ClientConfig.new_default() self._config = config self._oidcc = OpenIDCClient( oidcc_cache_identifier, config.id_provider_url, _ID_PROVIDER_MAPPING, config.client_id, ) def _send_and_check( self, rel_url: str, scopes=["profile", "offline_access"], **kwargs ) -> Response: resp = self._oidcc.send_request( self._config.api_url + rel_url, new_token=True, scopes=scopes, **kwargs, ) try: resp.raise_for_status() except RequestException as e: # digest the response into the message e.args = (f"{e}: {e.response.text}",) raise return resp
[docs] def handle_client(self, handle: str) -> "handles.HandleClient": """ Return a client class for making API calls specific to the given handle. Parameters ---------- handle : :class:`str` The handle of interest. Returns ------- :class:`handles.HandleClient` """ from .handles import HandleClient return HandleClient(self, handle)
[docs] def image_client(self, id: str) -> "images.ImageClient": """ Return a client class for making API calls specific to the given image. Parameters ---------- id : :class:`str` The ID of the image of interest. Returns ------- :class:`images.ImageClient` """ from .images import ImageClient return ImageClient(self, id)
[docs] def scene_client(self, id: str) -> "scenes.SceneClient": """ Return a client class for making API calls specific to the given scene. Parameters ---------- id : :class:`str` The ID of the scene of interest. Returns ------- :class:`scenes.SceneClient` """ from .scenes import SceneClient return SceneClient(self, id)
[docs] def find_images_by_wwt_url(self, wwt_url: str) -> List[ImageSummary]: """ Find images in the database associated with a particular "legacy" WWT data URL. This method corresponds to the :ref:`endpoint-POST-images-find-by-legacy-url` API endpoint. """ req = FindImagesByLegacyRequest(wwt_legacy_url=wwt_url) resp = self._send_and_check( "/images/find-by-legacy-url", json=_strip_nulls_in_place(req.to_dict()), ) resp = resp.json() resp.pop("error") resp = FindImagesByLegacyResponse.schema().load(resp) return resp.results
[docs] def get_home_timeline(self, page_num: int) -> List[SceneHydrated]: """ Get information about a group of scenes on the home timeline. Parameters ---------- page_num : int Which page to retrieve. Page zero gives the top items on the timeline, page one gives the next set, etc. Returns ------- A list of :class:`~wwt_api_client.constellations.data.SceneHydrated` items. Notes ----- The page size is not specified externally, nor is it guaranteed to be stable from one page to the next. If you care, look at the length of the list that you get back from an API. """ try: use_page_num = int(page_num) assert use_page_num >= 0 except Exception: raise ValueError(f"invalid page_num argument {page_num!r}") resp = self._send_and_check( "/scenes/home-timeline", http_method="GET", params={"page": use_page_num}, ) resp = resp.json() resp.pop("error") resp = TimelineResponse.schema().load(resp) return resp.results
[docs] def get_builtin_backgrounds(self) -> List[ImageSummary]: """ Get the list of builtin background imagery options. This method corresponds to the :ref:`endpoint-GET-images-builtin-backgrounds` API endpoint. """ resp = self._send_and_check("/images/builtin-backgrounds", http_method="GET") resp = resp.json() resp.pop("error") resp = BuiltinBackgroundsResponse.schema().load(resp) return resp.results