Source code for wwt_api_client

# -*- mode: python; coding: utf-8 -*-
# Copyright 2019-2023 the .Net Foundation
# Distributed under the MIT license

import requests
from urllib import parse as url_parse
from xml.sax.saxutils import escape as xml_escape
import warnings

__all__ = """
APIRequest
APIResponseError
Client
DEFAULT_API_BASE
LoginRequest
InvalidRequestError
ShowImageRequest
TileImageRequest
""".split()

DEFAULT_API_BASE = "http://www.worldwidetelescope.org"


[docs] class APIResponseError(Exception): """Raised when the API returns an HTTP error.""" def __init__(self, value): self.value = value def __str__(self): return str(self.value)
[docs] class InvalidRequestError(Exception): """Raised when an API request is not in a valid state""" def __init__(self, value): self.value = value def __str__(self): return str(self.value)
[docs] class Client(object): """The main object for accessing the WWT web services. Use this object to access the WWT APIs. Parameters ---------- api_base : URL string or None The base URL to use for accessing the WWT web APIs. Defaults to ``DEFAULT_API_BASE``, which is probably equal to "http://www.worldwidetelescope.org". The API base is configurable to make it possible to access testing servers, etc. This value should not end in a slash. """ _api_base = None _session = None def __init__(self, api_base=None): if api_base is None: api_base = DEFAULT_API_BASE self._api_base = api_base @property def session(self): """A ``requests.Session`` object used to talk to the WWT API server.""" if self._session is None: self._session = requests.session() return self._session
[docs] def login( self, user_guid="00000000-0000-0000-0000-000000000000", client_version="6.0.0.0", equinox_version_or_later=True, ): """Create a :ref:`Login <endpoint-Login>` request object. Parameters are assigned to attributes of the return value; see :class:`the class documentation <LoginRequest>` for descriptions. Examples -------- The arguments are for informational purposes only and so may be left at their defaults. >>> from wwt_api_client import Client >>> req = Client().login() >>> info = req.send() # textual data about versions on server Returns ------- request : an initialized :class:`LoginRequest` object The request. """ req = LoginRequest(self) req.user_guid = user_guid req.client_version = client_version req.equinox_version_or_later = equinox_version_or_later return req
[docs] def show_image( self, image_url=None, name=None, credits=None, credits_url=None, dec_deg=0.0, ra_deg=0.0, reverse_parity=False, rotation_deg=0.0, scale_arcsec=1.0, thumbnail_url=None, x_offset_pixels=0.0, y_offset_pixels=0.0, ): """Create a :ref:`ShowImage <endpoint-ShowImage>` request object. Parameters are assigned to attributes of the return value; see :class:`the class documentation <ShowImageRequest>` for descriptions. Examples -------- The only two essential arguments are ``image_url`` and ``name``:: >>> from wwt_api_client import Client >>> req = Client().show_image('http://example.com/space.jpg', 'My Image') >>> print(req.send()[:10]) # prints start of a WTML XML document <?xml vers Returns ------- request : an initialized :class:`ShowImageRequest` object The request. """ req = ShowImageRequest(self) req.image_url = image_url req.name = name req.credits = credits req.credits_url = credits_url req.dec_deg = dec_deg req.ra_deg = ra_deg req.reverse_parity = reverse_parity req.rotation_deg = rotation_deg req.scale_arcsec = scale_arcsec req.thumbnail_url = thumbnail_url req.x_offset_pixels = x_offset_pixels req.y_offset_pixels = y_offset_pixels return req
[docs] def tile_image( self, image_url=None, credits=None, credits_url=None, dec_deg=0.0, ra_deg=0.0, rotation_deg=0.0, scale_deg=1.0, thumbnail_url=None, x_offset_deg=0.0, y_offset_deg=0.0, ): """Create a :ref:`TileImage <endpoint-TileImage>` request object. Parameters are assigned to attributes of the return value; see :class:`the class documentation <TileImageRequest>` for descriptions. Examples -------- The only essential argument is ``image_url``, if your image has appropriate AVM tags:: >>> from wwt_api_client import Client >>> req = Client().tile_image('http://www.spitzer.caltech.edu/uploaded_files/images/0009/0848/sig12-011.jpg') >>> print(req.send()[:10]) # prints start of a WTML XML document <?xml vers Returns ------- request : an initialized :class:`TileImageRequest` object The request. """ req = TileImageRequest(self) req.image_url = image_url req.credits = credits req.credits_url = credits_url req.dec_deg = dec_deg req.ra_deg = ra_deg req.rotation_deg = rotation_deg req.scale_deg = scale_deg req.thumbnail_url = thumbnail_url req.x_offset_deg = x_offset_deg req.y_offset_deg = y_offset_deg return req
def _get_our_encoding(): """Get the encoding that we will use to convert bytes to Unicode. We delegate to ``sys.getdefaultencoding()``, but with the wrinkle that if that returns "ascii", as sometimes happens, we upgrade to UTF-8. """ import sys enc = sys.getdefaultencoding() if enc == "ascii": return "utf-8" return enc def _maybe_as_bytes(obj, xml_esc=False, in_enc=None, out_enc="utf-8"): import codecs if obj is None: return None if in_enc is None: in_enc = _get_our_encoding() if isinstance(obj, bytes): # If we don't special-case, b'abc' becomes "b'abc'". # # It would also be nice if we could validate that *obj* is # appropriately encoded (when in_enc = out_enc) without converting and # de-converting it to Unicode, but I don't see how to do that, and for # this API I don't expect the overhead to be significant. text = codecs.decode(obj, in_enc) else: text = str(obj) if xml_esc: text = xml_escape(text, {'"': "&quot;"}) return codecs.encode(text, out_enc) def _is_textable(obj, none_ok=False): if obj is None: return none_ok if isinstance(obj, bytes): import codecs try: codecs.decode(obj, _get_our_encoding()) except Exception: return False return True try: str(obj) except Exception: return False return True def _is_absurl(obj, none_ok=False): if obj is None: return none_ok # We're monkey-see-monkey-do here, w.r.t. the proper way to think about # encoding and decoding with URLs. The urllib approach seems to be to only # allow ASCII in and out. import codecs if isinstance(obj, bytes): # If we don't special-case, b'abc' becomes "b'abc'". try: text = codecs.decode(obj, "ascii") except Exception: return False else: try: text = str(obj) except Exception: return False # We also need to be able to go the other way: try: codecs.encode(text, "ascii") except Exception: return False # OK, now does it parse? try: parsed = url_parse.urlparse(text) except Exception: return False return bool(parsed.netloc) def _is_scalar(obj, none_ok=False): if obj is None: return none_ok try: val = float(obj) except Exception: return False import math return not ( math.isinf(val) or math.isnan(val) ) # math.isfinite() only available in 3.x
[docs] class APIRequest(object): """A base class represent various WWT API requests. This class provides a generic representation of WWT API requests. For instance, every API request instance provides a :meth:`make_request` method that gets the underlying HTTP request as a ``requests.Request`` class. You don’t generally need to instantiate requests yourself. Instead, use the methods on :class:`Client` to create requests. Parameters ---------- client : :class:`Client` The client with which this request is associated. """ _client = None def __init__(self, client): self._client = client
[docs] def invalidity_reason(self): """Check whether the parameters of this request are valid. Examples -------- You can manually check if a request is correctly set up:: >>> from wwt_api_client import Client >>> req = Client().show_image('http://example.com/space.jpg', 'My Image') >>> assert req.invalidity_reason() is None Returns ------- reason : string or None If None, indicates that this request is valid. Otherwise, the returned string explains what about the request’ parameters is invalid. """ return None
[docs] def make_request(self): """Generate a ``requests.Request`` from the current parameters. This method returns a ``requests.Request`` object ready for sending to the API server. Examples -------- Get the URL that will be accessed for a request:: >>> from urllib.parse import urlparse >>> from wwt_api_client import Client >>> req = Client().show_image('http://example.com/space.jpg', 'My Image') >>> parsed_url = urlparse(req.make_request().prepare().url) >>> print(parsed_url.path) /WWTWeb/ShowImage.aspx Returns ------- request : ``requests.Request`` object The HTTP request. """ raise NotImplementedError()
def _process_response(self, resp): """Given the HTTP response from the server, process it into whatever return form is most appropriate for this API call. The default is to return it as text.""" return resp.text
[docs] def send(self, raw_response=False): """Issue the request and return its result. The request’s validity will be checked before sending. Examples -------- Send a :ref:`ShowImage <endpoint-ShowImage>` request: >>> from wwt_api_client import Client >>> req = Client().show_image('http://example.com/space.jpg', 'My Image') >>> print(req.send()[:10]) # prints start of a WTML XML document <?xml vers Parameters ---------- raw_response : bool, optional, default False If True, the raw ``requests`` response will be returned rather than the processed version. Returns ------- response : varies The server reponse, postprocesed into whatever form makes the most sense for this API. The default is text. Raises ------ :class:`InvalidRequestError` Raised if the request parameters are invalid. :class:`APIResponseError` Raised if the API call results in an HTTP error code. """ invalid = self.invalidity_reason() if invalid is not None: raise InvalidRequestError(invalid) resp = self._client.session.send(self.make_request().prepare()) if not resp.ok: raise APIResponseError(resp.text) if raw_response: return resp return self._process_response(resp)
[docs] def to_text(self): """Issue the request and return its results as text.""" return self.send(raw_response=True).text
[docs] def to_xml(self): """Issue the request and return its results as parsed XML.""" from xml.etree import ElementTree as etree text = self.send(raw_response=True).text return etree.fromstring(text)
[docs] class LoginRequest(APIRequest): """Indicate a client login to the server.""" user_guid = "00000000-0000-0000-0000-000000000000" "A GUID associated with the user logging in. The server doesn't track these." client_version = "6.0.0.0" "The version of the client logging in." equinox_version_or_later = True 'Whether this client is of the "Equinox" release (~2008) or later.'
[docs] def invalidity_reason(self): if not _is_textable(self.user_guid): return '"user_guid" must be a string-like object' if not _is_textable(self.client_version): return '"client_version" must be a string-like object' if not isinstance(self.equinox_version_or_later, bool): return '"equinox_version_or_later" must be a boolean' return None
[docs] def make_request(self): params = [ ("user", _maybe_as_bytes(self.user_guid)), ("Version", _maybe_as_bytes(self.client_version)), ] if self.equinox_version_or_later: params.append(("Equinox", "true")) return requests.Request( method="GET", url=self._client._api_base + "/WWTWeb/login.aspx", params=params, )
# TODO: connect this to wwt_data_formats!
[docs] class ShowImageRequest(APIRequest): """Request a WTML XML document suitable for showing an image in a client. This request connects to the :ref:`ShowImage <endpoint-ShowImage>` endpoint. Perhaps counterintuitively, this API returns a `WTML collection <https://docs.worldwidetelescope.org/data-guide/1/data-file-formats/collections/>`_ XML document that points to a single web-accessible image no larger than 2048×2048 pixels. The XML document is a fairly straightforward transcription of the URL parameters that are passed to the API call. Therefore this API is most useful when you are using some *other* web API that requires a URL to a WTML file — by passing it a URL involving this endpoint, you can point at a “virtual” WTML file that tells WWT how to show an image. Only the ``name`` and ``image_url`` parameters are essential:: >>> from wwt_api_client import Client >>> req = Client().show_image('http://example.com/space.jpg', 'My Image') >>> print(req.send()[:10]) # prints start of a WTML XML document <?xml vers The image to be shown must be less than 2048×2048 in size and should use a tangential projection. For details, see the documentation of the :ref:`ShowImage <endpoint-ShowImage>` endpoint. """ credits = None "Free text describing where the image came from." credits_url = None "Absolute URL of a webpage with more information about the image." dec_deg = 0.0 "The declination at which to center the view, in degrees." image_url = None "Absolute URL of the image to show." name = None """A name to give the image an its enclosing ``<Place>``. Commas will be stripped by the server. """ ra_deg = 0.0 "The right ascension at which to center the view, in degrees." reverse_parity = False "If true, the image will be flipped left-right before display." rotation_deg = 0.0 "How much to rotate the image in an east-from-north sense, in degrees." scale_arcsec = 1.0 "The angular size of each image pixel, in arcseconds. Pixels must be square." thumbnail_url = None "Absolute URL of a 96×45 pixel image thumbnail used to represent the ``<Place>``." x_offset_pixels = 0.0 "The horizontal offset of the image’s lower-left corner from the view center, in pixels." y_offset_pixels = 0.0 "The vertical offset of the image’s lower-left corner from the view center, in pixels."
[docs] def invalidity_reason(self): if not _is_textable(self.credits, none_ok=True): return '"credits" must be None or a string-like object' if not _is_absurl(self.credits_url, none_ok=True): return '"credits_url" must be None or a valid absolute URL' if not _is_scalar(self.dec_deg): return '"dec_deg" must be a number' dec = float(self.dec_deg) if dec < -90 or dec > 90: return '"dec_deg" must be between -90 and 90' if not _is_absurl(self.image_url): return '"image_url" must be a valid absolute URL' if not _is_textable(self.name): return '"name" must be a string or an object that can be stringified' if "," in str(self.name): warnings.warn( "ShowImage name {0} contains commas, which will be stripped " "by the server".format(self.name), UserWarning, ) if not _is_scalar(self.ra_deg): return '"ra_deg" must be a number' if not isinstance(self.reverse_parity, bool): return '"reverse_parity" must be a boolean' if not _is_scalar(self.rotation_deg): return '"rotation_deg" must be a number' if not _is_scalar(self.scale_arcsec): return '"scale_arcsec" must be a number' if float(self.scale_arcsec) == 0.0: return '"scale_arcsec" must not be zero' if not _is_absurl(self.thumbnail_url, none_ok=True): return '"thumbnail_url" must be None or a valid absolute URL' if not _is_scalar(self.x_offset_pixels): return '"x_offset_pixels" must be a number' if not _is_scalar(self.y_offset_pixels): return '"y_offset_pixels" must be a number' return None
[docs] def make_request(self): params = [ ("dec", "%.18e" % float(self.dec_deg)), ( "imageurl", _maybe_as_bytes( self.image_url, xml_esc=True, in_enc="ascii", out_enc="ascii" ), ), ("name", _maybe_as_bytes(self.name, xml_esc=True)), ("ra", "%.18e" % (float(self.ra_deg) % 360)), # The API clips, but we wrap ( "rotation", "%.18e" % (float(self.rotation_deg) + 180), ), # API is bizarre here ("scale", "%.18e" % float(self.scale_arcsec)), ("wtml", "t"), ("x", "%.18e" % float(self.x_offset_pixels)), ("y", "%.18e" % float(self.y_offset_pixels)), ] if self.credits is not None: params.append(("credits", _maybe_as_bytes(self.credits, xml_esc=True))) if self.credits_url is not None: params.append( ( "creditsUrl", _maybe_as_bytes( self.credits_url, xml_esc=True, in_enc="ascii", out_enc="ascii" ), ) ) if self.reverse_parity: params.append(("reverseparity", "t")) if self.thumbnail_url is not None: params.append( ( "thumb", _maybe_as_bytes( self.thumbnail_url, xml_esc=True, in_enc="ascii", out_enc="ascii", ), ) ) return requests.Request( method="GET", url=self._client._api_base + "/WWTWeb/ShowImage.aspx", params=params, )
# TODO: connect this to wwt_data_formats!
[docs] class TileImageRequest(APIRequest): """Tile a large image on the server and obtain a WTML XML document suitable for displaying it in a client. FITS images are not supported. This request connects to the :ref:`TileImage <endpoint-TileImage>` endpoint. This API fetches an image from the web, breaks it into tiles on the server, and returns a `WTML collection <https://docs.worldwidetelescope.org/data-guide/1/data-file-formats/collections/>`_ XML document describing the resulting dataset. If the input image has correct `AVM <https://www.virtualastronomy.org/avm_metadata.php>`_ tags, only the ``image_url`` parameter is essential:: >>> from wwt_api_client import Client >>> req = Client().tile_image('http://www.spitzer.caltech.edu/uploaded_files/images/0009/0848/sig12-011.jpg') >>> print(req.send()[:10]) # prints start of a WTML XML document <?xml vers However, the server-side AVM parsing can fail even when the image has AVM tags that look correct, and there is no feedback about where exactly the error occurs. Specifying the astrometric information through parameters to this API call is safer. For details, see the documentation of the :ref:`TileImage <endpoint-TileImage>` endpoint. """ credits = None "Free text describing where the image came from." credits_url = None "Absolute URL of a webpage with more information about the image." dec_deg = None "The declination at which to center the view, in degrees." image_url = None """Absolute URL of the image to tile. Images supported by the C# ``System.Drawing.Bitmap`` class are allowed. These are BMP, GIF, JPG, PNG, and TIF. """ ra_deg = None "The right ascension at which to center the view, in degrees." rotation_deg = None "How much to rotate the image in an east-from-north sense, in degrees." scale_deg = None "The angular size of each image pixel, in degrees. Pixels must be square." thumbnail_url = None """Absolute URL of a 96×45 pixel image thumbnail used to represent the ``<Place>``. If unspecified, a URL that resolves to a small version of the uploaded image will be used. """ x_offset_deg = None """The horizontal offset between the image and view centers. Positive numbers move the image to the right relative to the viewport center. """ y_offset_deg = None """The vertical offset between the image and view centers. Positive numbers move the image up relative to the viewport center. """
[docs] def invalidity_reason(self): if not _is_textable(self.credits, none_ok=True): return '"credits" must be None or a string-like object' if not _is_absurl(self.credits_url, none_ok=True): return '"credits_url" must be None or a valid absolute URL' if not _is_scalar(self.dec_deg, none_ok=True): return '"dec_deg" must be None or a number' if self.dec_deg is not None: dec = float(self.dec_deg) if dec < -90 or dec > 90: return '"dec_deg" must be between -90 and 90' if not _is_absurl(self.image_url): return '"image_url" must be a valid absolute URL' if not _is_scalar(self.ra_deg, none_ok=True): return '"ra_deg" must be None or a number' if not _is_scalar(self.rotation_deg, none_ok=True): return '"rotation_deg" must be None or a number' if not _is_scalar(self.scale_deg, none_ok=True): return '"scale_deg" must be a number' if self.scale_deg is not None: scale = float(self.scale_deg) if scale == 0.0: return '"scale_deg" must not be zero' if not _is_absurl(self.thumbnail_url, none_ok=True): return '"thumbnail_url" must be None or a valid absolute URL' if not _is_scalar(self.x_offset_deg, none_ok=True): return '"x_offset_deg" must be a number' if not _is_scalar(self.y_offset_deg, none_ok=True): return '"y_offset_deg" must be a number' return None
[docs] def make_request(self): params = [ ( "imageurl", _maybe_as_bytes( self.image_url, xml_esc=True, in_enc="ascii", out_enc="ascii" ), ), ] if self.credits is not None: params.append(("credits", _maybe_as_bytes(self.credits, xml_esc=True))) if self.credits_url is not None: params.append( ( "creditsUrl", _maybe_as_bytes( self.credits_url, xml_esc=True, in_enc="ascii", out_enc="ascii" ), ) ) if self.dec_deg is not None: params.append(("dec", "%.18e" % float(self.dec_deg))) if self.ra_deg is not None: params.append(("ra", "%.18e" % float(self.ra_deg))) if self.rotation_deg is not None: params.append( ("rotation", "%.18e" % (float(self.rotation_deg) + 180)) ) # API is bizarre here if self.scale_deg is not None: params.append(("scale", "%.18e" % float(self.scale_deg))) if self.thumbnail_url is not None: params.append( ( "thumb", _maybe_as_bytes( self.thumbnail_url, xml_esc=True, in_enc="ascii", out_enc="ascii", ), ) ) if self.x_offset_deg is not None: params.append(("x", "%.18e" % float(self.x_offset_deg))) if self.y_offset_deg is not None: params.append(("y", "%.18e" % float(self.y_offset_deg))) return requests.Request( method="GET", url=self._client._api_base + "/WWTWeb/TileImage.aspx", params=params, )