diff --git a/LICENSE b/LICENSE index 752835f..764a44b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,18 @@ The MIT License -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. -* Neither the name of the WorldWide Telescope project nor the names of its - contributors may be used to endorse or promote products derived from this - software without specific prior written permission. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/ci/azure-build-and-test.yml b/ci/azure-build-and-test.yml index e6b3e9e..818641b 100644 --- a/ci/azure-build-and-test.yml +++ b/ci/azure-build-and-test.yml @@ -62,6 +62,7 @@ jobs: source activate-conda.sh conda activate build set -x + \conda install -y httpretty mock pytest pytest-cov pytest-mock pytest wwt_api_client displayName: Test @@ -87,7 +88,7 @@ jobs: source activate-conda.sh conda activate build set -x - \conda install -y pytest-cov + \conda install -y httpretty mock pytest pytest-cov pytest-mock pytest --cov-report=xml --cov=wwt_api_client wwt_api_client displayName: Test with coverage diff --git a/ci/azure-sdist.yml b/ci/azure-sdist.yml index 9776563..96fd6ca 100644 --- a/ci/azure-sdist.yml +++ b/ci/azure-sdist.yml @@ -66,7 +66,8 @@ jobs: conda activate conda config --add channels conda-forge conda install -y \ - requests + requests \ + wwt_data_formats pip install openidc_client displayName: Set up dependencies diff --git a/docs/api.rst b/docs/api.rst deleted file mode 100644 index da7a704..0000000 --- a/docs/api.rst +++ /dev/null @@ -1,14 +0,0 @@ -API Documentation -================= - -.. automodapi:: wwt_api_client - :no-inheritance-diagram: - :no-inherited-members: - -.. automodapi:: wwt_api_client.communities - :no-inheritance-diagram: - :no-inherited-members: - -.. automodapi:: wwt_api_client.enums - :no-inheritance-diagram: - :no-inherited-members: diff --git a/docs/api/wwt_api_client.LoginRequest.rst b/docs/api/wwt_api_client.LoginRequest.rst new file mode 100644 index 0000000..efa19c4 --- /dev/null +++ b/docs/api/wwt_api_client.LoginRequest.rst @@ -0,0 +1,33 @@ +LoginRequest +============ + +.. currentmodule:: wwt_api_client + +.. autoclass:: LoginRequest + :show-inheritance: + + .. rubric:: Attributes Summary + + .. autosummary:: + + ~LoginRequest.client_version + ~LoginRequest.equinox_version_or_later + ~LoginRequest.user_guid + + .. rubric:: Methods Summary + + .. autosummary:: + + ~LoginRequest.invalidity_reason + ~LoginRequest.make_request + + .. rubric:: Attributes Documentation + + .. autoattribute:: client_version + .. autoattribute:: equinox_version_or_later + .. autoattribute:: user_guid + + .. rubric:: Methods Documentation + + .. automethod:: invalidity_reason + .. automethod:: make_request diff --git a/docs/api/wwt_api_client.communities.rst b/docs/api/wwt_api_client.communities.rst new file mode 100644 index 0000000..be1f0ae --- /dev/null +++ b/docs/api/wwt_api_client.communities.rst @@ -0,0 +1,3 @@ +.. automodapi:: wwt_api_client.communities + :no-inheritance-diagram: + :inherited-members: diff --git a/docs/api/wwt_api_client.enums.rst b/docs/api/wwt_api_client.enums.rst new file mode 100644 index 0000000..0d702f6 --- /dev/null +++ b/docs/api/wwt_api_client.enums.rst @@ -0,0 +1,3 @@ +.. automodapi:: wwt_api_client.enums + :no-inheritance-diagram: + :inherited-members: diff --git a/docs/api/wwt_api_client.rst b/docs/api/wwt_api_client.rst new file mode 100644 index 0000000..3c4c232 --- /dev/null +++ b/docs/api/wwt_api_client.rst @@ -0,0 +1,3 @@ +.. automodapi:: wwt_api_client + :no-inheritance-diagram: + :inherited-members: diff --git a/docs/conf.py b/docs/conf.py index 996b310..d973c36 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,10 +1,11 @@ -# Configuration file for the Sphinx documentation builder. +# -*- coding: utf-8 -*- project = "wwt_api_client" author = "WorldWide Telescope project" copyright = "2019-2023 " + author release = "0.dev0" # cranko project-version +version = ".".join(release.split(".")[:2]) extensions = [ "sphinx.ext.autodoc", @@ -17,12 +18,52 @@ extensions = [ "numpydoc", ] -master_doc = "index" templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" +language = "en" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +pygments_style = "sphinx" +todo_include_todos = False -numpydoc_class_members_toctree = False - -html_theme = "alabaster" +html_theme = "bootstrap-astropy" +html_theme_options = { + "logotext1": "wwt_api_client", + "logotext2": "", + "logotext3": ":docs", + "astropy_project_menubar": False, +} html_static_path = ["_static"] +htmlhelp_basename = "wwtapiclientdoc" + +intersphinx_mapping = { + "python": ( + "https://docs.python.org/3/", + (None, "http://data.astropy.org/intersphinx/python3.inv"), + ), + "requests": ("https://requests.readthedocs.io/en/stable/", None), +} + +numpydoc_show_class_members = False + +nitpicky = True +nitpick_ignore = [ + # Traitlets stuff that we have to ignore. This is all due to our need to + # turn on :inherited-members: in api.rst due to a sphinx-automodapi bug (see + # comment in api.rst). + ("py:attr", "class_init"), + ("py:attr", "name"), + ("py:attr", "this_class"), + ("py:class", "traitlets.traitlets.HasDescriptors"), + ("py:class", "traitlets.traitlets.MetaHasDescriptors"), + ("py:class", "traitlets.traitlets.MetaHasTraits"), + ("py:obj", "handler"), + ("py:obj", "remove"), +] + +default_role = "obj" + html_logo = "images/logo.png" + +linkcheck_retries = 5 +linkcheck_timeout = 10 diff --git a/docs/endpoints/login.rst b/docs/endpoints/login.rst index 61d4406..1578ffe 100644 --- a/docs/endpoints/login.rst +++ b/docs/endpoints/login.rst @@ -1,6 +1,6 @@ .. _endpoint-Login: -``wwtweb/login.aspx`` +``WWTWeb/Login.aspx`` ===================== Windows clients invoke this API when they boot up. They send advisory version diff --git a/docs/index.rst b/docs/index.rst index 7bc2d05..1dadfca 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,27 +1,40 @@ -wwt_api_client: Pythonic access to the WorldWide Telescope web services -======================================================================= +=================================================================== +wwt_api_client: Pythonic access to WorldWide Telescope web services +=================================================================== -The AAS `WorldWide Telescope `_ is a free +AAS `WorldWide Telescope `_ is a free and powerful visualization engine developed by the `American Astronomical Society `_ that can display astronomical and planetary data. This engine is powered by a large (multi-terabyte) collection of astronomical -survey data stored in the cloud. The ``wwt_api_client`` package allows you to +survey data stored in the cloud. The `wwt_api_client`_ package allows you to invoke the web APIs that constitute the WWT backend API. +.. _wwt_api_client: https://wwt-api-client.readthedocs.io/ -User guide ----------- + +Table of Contents +================= .. toctree:: :maxdepth: 2 installation endpoints/index.rst - api + + +API Reference +============= + +.. toctree:: + :maxdepth: 1 + + api/wwt_api_client + api/wwt_api_client.communities + api/wwt_api_client.enums Getting help ------------- +============ If you run into any issues when using ``wwt_api_client``, please open an issue `on its GitHub repository @@ -29,7 +42,21 @@ If you run into any issues when using ``wwt_api_client``, please open an issue Acknowledgments ---------------- +=============== + +`wwt_api_client`_ is part of the `AAS`_ `WorldWide Telescope`_ system, a +`.NET Foundation`_ project managed by the non-profit `American Astronomical +Society`_ (AAS). Work on WWT has been supported by the AAS, the US `National +Science Foundation`_ (grants 1550701_, 1642446_, and 2004840_), the `Gordon and Betty +Moore Foundation`_, and `Microsoft`_. + +.. _.NET Foundation: https://dotnetfoundation.org/ +.. _AAS: https://aas.org/ +.. _American Astronomical Society: https://aas.org/ +.. _National Science Foundation: https://www.nsf.gov/ +.. _1550701: https://www.nsf.gov/awardsearch/showAward?AWD_ID=1550701 +.. _1642446: https://www.nsf.gov/awardsearch/showAward?AWD_ID=1642446 +.. _2004840: https://www.nsf.gov/awardsearch/showAward?AWD_ID=2004840 +.. _Gordon and Betty Moore Foundation: https://www.moore.org/ +.. _Microsoft: https://www.microsoft.com/ -Work on ``wwt_api_client`` is funded through the American Astronomical -Society’s support of the WorldWide Telescope project. diff --git a/readthedocs.yml b/readthedocs.yml index b387552..bd4e377 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -1,8 +1,8 @@ build: - image: latest + image: latest python: - version: 3.7 + version: 3.10 pip_install: true extra_requirements: ['docs'] diff --git a/setup.py b/setup.py index 9d8d6a6..7403d91 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,6 @@ #! /usr/bin/env python -# -*- mode: python; coding: utf-8 -*- -# Copyright 2019-2020 the .NET Foundation -# Distributed under the terms of the revised (3-clause) BSD license. +# Copyright 2019-2023 the .NET Foundation +# Distributed under the MIT license from setuptools import setup @@ -52,7 +51,7 @@ setup_args = dict( classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Science/Research", - "License :: OSI Approved :: BSD License", + "License :: OSI Approved :: MIT License", "Topic :: Multimedia :: Graphics", "Programming Language :: Python", "Programming Language :: Python :: 3.7", @@ -64,19 +63,21 @@ setup_args = dict( include_package_data=True, install_requires=[ "requests", + "wwt_data_formats", ], extras_require={ "test": [ "httpretty", + "mock", "pytest", "pytest-cov", "pytest-mock", ], "docs": [ - "sphinx>=1.6", - "sphinx-automodapi", + "astropy-sphinx-theme", "numpydoc", - "sphinx_rtd_theme", + "sphinx", + "sphinx-automodapi", ], }, entry_points={}, diff --git a/wwt_api_client/__init__.py b/wwt_api_client/__init__.py index 417edc8..741cf9b 100644 --- a/wwt_api_client/__init__.py +++ b/wwt_api_client/__init__.py @@ -1,36 +1,29 @@ # -*- mode: python; coding: utf-8 -*- -# Copyright 2019-2020 the .Net Foundation -# Distributed under the terms of the revised (3-clause) BSD license. - -from __future__ import absolute_import, division, print_function +# Copyright 2019-2023 the .Net Foundation +# Distributed under the MIT license import requests -import six -from six.moves.urllib import parse as url_parse +from urllib import parse as url_parse from xml.sax.saxutils import escape as xml_escape import warnings -from ._version import version_info, __version__ # noqa - -__all__ = ''' -__version__ +__all__ = """ APIRequest APIResponseError Client DEFAULT_API_BASE +LoginRequest InvalidRequestError ShowImageRequest TileImageRequest -version_info -'''.split() +""".split() -DEFAULT_API_BASE = 'http://www.worldwidetelescope.org' +DEFAULT_API_BASE = "http://www.worldwidetelescope.org" class APIResponseError(Exception): - """Raised when the API returns an HTTP error. + """Raised when the API returns an HTTP error.""" - """ def __init__(self, value): self.value = value @@ -39,9 +32,8 @@ class APIResponseError(Exception): class InvalidRequestError(Exception): - """Raised when an API request is not in a valid state + """Raised when an API request is not in a valid state""" - """ def __init__(self, value): self.value = value @@ -58,12 +50,13 @@ class Client(object): ---------- api_base : URL string or None The base URL to use for accessing the WWT web APIs. Defaults to - :data:`DEFAULT_API_BASE`, which is probably equal 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 @@ -81,8 +74,12 @@ class Client(object): return self._session - def login(self, user_guid='00000000-0000-0000-0000-000000000000', client_version='6.0.0.0', - equinox_version_or_later=True): + 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 ` request object. Parameters are assigned to attributes of the return value; see @@ -109,9 +106,21 @@ class Client(object): req.equinox_version_or_later = equinox_version_or_later return req - 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): + 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 ` request object. Parameters are assigned to attributes of the return value; see @@ -147,9 +156,19 @@ class Client(object): req.y_offset_pixels = y_offset_pixels return req - 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): + 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 ` request object. Parameters are assigned to attributes of the return value; see @@ -195,12 +214,12 @@ def _get_our_encoding(): import sys enc = sys.getdefaultencoding() - if enc == 'ascii': - return 'utf-8' + if enc == "ascii": + return "utf-8" return enc -def _maybe_as_bytes(obj, xml_esc=False, in_enc=None, out_enc='utf-8'): +def _maybe_as_bytes(obj, xml_esc=False, in_enc=None, out_enc="utf-8"): import codecs if obj is None: @@ -209,7 +228,7 @@ def _maybe_as_bytes(obj, xml_esc=False, in_enc=None, out_enc='utf-8'): if in_enc is None: in_enc = _get_our_encoding() - if isinstance(obj, six.binary_type): + 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 @@ -218,10 +237,10 @@ def _maybe_as_bytes(obj, xml_esc=False, in_enc=None, out_enc='utf-8'): # this API I don't expect the overhead to be significant. text = codecs.decode(obj, in_enc) else: - text = six.text_type(obj) + text = str(obj) if xml_esc: - text = xml_escape(text, {'"': '"'}) + text = xml_escape(text, {'"': """}) return codecs.encode(text, out_enc) @@ -230,7 +249,7 @@ def _is_textable(obj, none_ok=False): if obj is None: return none_ok - if isinstance(obj, six.binary_type): + if isinstance(obj, bytes): import codecs try: @@ -240,7 +259,7 @@ def _is_textable(obj, none_ok=False): return True try: - six.text_type(obj) + str(obj) except Exception: return False return True @@ -255,21 +274,21 @@ def _is_absurl(obj, none_ok=False): # allow ASCII in and out. import codecs - if isinstance(obj, six.binary_type): + if isinstance(obj, bytes): # If we don't special-case, b'abc' becomes "b'abc'". try: - text = codecs.decode(obj, 'ascii') + text = codecs.decode(obj, "ascii") except Exception: return False else: try: - text = six.text_type(obj) + text = str(obj) except Exception: return False # We also need to be able to go the other way: try: - codecs.encode(text, 'ascii') + codecs.encode(text, "ascii") except Exception: return False @@ -292,7 +311,10 @@ def _is_scalar(obj, none_ok=False): return False import math - return not (math.isinf(val) or math.isnan(val)) # math.isfinite() only available in 3.x + + return not ( + math.isinf(val) or math.isnan(val) + ) # math.isfinite() only available in 3.x class APIRequest(object): @@ -312,6 +334,7 @@ class APIRequest(object): The client with which this request is associated. """ + _client = None def __init__(self, client): @@ -348,7 +371,7 @@ class APIRequest(object): -------- Get the URL that will be accessed for a request:: - >>> from six.moves.urllib.parse import urlparse + >>> 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) @@ -422,22 +445,22 @@ class APIRequest(object): 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) class LoginRequest(APIRequest): - """Indicate a client login to the server. + """Indicate a client login to the server.""" - """ - user_guid = '00000000-0000-0000-0000-000000000000' + 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' + 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." + 'Whether this client is of the "Equinox" release (~2008) or later.' def invalidity_reason(self): if not _is_textable(self.user_guid): @@ -453,22 +476,23 @@ class LoginRequest(APIRequest): def make_request(self): params = [ - ('user', _maybe_as_bytes(self.user_guid)), - ('Version', _maybe_as_bytes(self.client_version)), + ("user", _maybe_as_bytes(self.user_guid)), + ("Version", _maybe_as_bytes(self.client_version)), ] if self.equinox_version_or_later: - params.append(('Equinox', 'true')) + params.append(("Equinox", "true")) return requests.Request( - method = 'GET', - url = self._client._api_base + '/WWTWeb/login.aspx', - params = params, + method="GET", + url=self._client._api_base + "/WWTWeb/login.aspx", + params=params, ) # TODO: connect this to wwt_data_formats! + class ShowImageRequest(APIRequest): """Request a WTML XML document suitable for showing an image in a client. @@ -497,6 +521,7 @@ class ShowImageRequest(APIRequest): ` endpoint. """ + credits = None "Free text describing where the image came from." @@ -555,9 +580,12 @@ class ShowImageRequest(APIRequest): 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 "," 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' @@ -571,7 +599,7 @@ class ShowImageRequest(APIRequest): if not _is_scalar(self.scale_arcsec): return '"scale_arcsec" must be a number' - if float(self.scale_arcsec) == 0.: + if float(self.scale_arcsec) == 0.0: return '"scale_arcsec" must not be zero' if not _is_absurl(self.thumbnail_url, none_ok=True): @@ -587,38 +615,64 @@ class ShowImageRequest(APIRequest): 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)), + ("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))) + 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'))) + 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')) + 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'))) + 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, + method="GET", + url=self._client._api_base + "/WWTWeb/ShowImage.aspx", + params=params, ) # TODO: connect this to wwt_data_formats! + 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. @@ -647,6 +701,7 @@ class TileImageRequest(APIRequest): ` endpoint. """ + credits = None "Free text describing where the image came from." @@ -691,6 +746,7 @@ class TileImageRequest(APIRequest): Positive numbers move the image up relative to the viewport center. """ + def invalidity_reason(self): if not _is_textable(self.credits, none_ok=True): return '"credits" must be None or a string-like object' @@ -720,7 +776,7 @@ class TileImageRequest(APIRequest): if self.scale_deg is not None: scale = float(self.scale_deg) - if scale == 0.: + if scale == 0.0: return '"scale_deg" must not be zero' if not _is_absurl(self.thumbnail_url, none_ok=True): @@ -736,38 +792,62 @@ class TileImageRequest(APIRequest): def make_request(self): params = [ - ('imageurl', _maybe_as_bytes(self.image_url, xml_esc=True, in_enc='ascii', out_enc='ascii')), + ( + "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))) + 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'))) + 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))) + params.append(("dec", "%.18e" % float(self.dec_deg))) if self.ra_deg is not None: - params.append(('ra', '%.18e' % float(self.ra_deg))) + 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 + 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))) + 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'))) + 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))) + 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))) + params.append(("y", "%.18e" % float(self.y_offset_deg))) return requests.Request( - method = 'GET', - url = self._client._api_base + '/WWTWeb/TileImage.aspx', - params = params, + method="GET", + url=self._client._api_base + "/WWTWeb/TileImage.aspx", + params=params, ) diff --git a/wwt_api_client/_version.py b/wwt_api_client/_version.py deleted file mode 100644 index 4e031f8..0000000 --- a/wwt_api_client/_version.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright 2019 the .Net Foundation -# Distributed under the terms of the revised (3-clause) BSD license. -# -# NOTE: this file explicitly does *not* have a mode/encoding line, because the -# way that we read it in setupbase.py breaks on Python 2.7 with a SyntaxError. - -version_info = (0, 1, 0, 'dev', 0) - -_specifier_ = {'alpha': 'a', 'beta': 'b', 'candidate': 'rc', 'final': '', 'dev': 'dev'} -_ext = '' if version_info[3] == 'final' else _specifier_[version_info[3]] + str(version_info[4]) -__version__ = '%s.%s.%s%s' % (version_info[0], version_info[1], version_info[2], _ext) diff --git a/wwt_api_client/communities.py b/wwt_api_client/communities.py index f76e351..7d47756 100644 --- a/wwt_api_client/communities.py +++ b/wwt_api_client/communities.py @@ -1,5 +1,5 @@ # Copyright 2019-2020 the .NET Foundation -# Distributed under the terms of the revised (3-clause) BSD license. +# Distributed under the MIT license """Interacting with the WWT Communities APIs.""" @@ -11,7 +11,7 @@ from urllib.parse import parse_qs, urlparse from . import APIRequest, Client, enums -__all__ = ''' +__all__ = """ CommunitiesAPIRequest CommunitiesClient CreateCommunityRequest @@ -22,15 +22,16 @@ GetMyProfileRequest GetProfileEntitiesRequest IsUserRegisteredRequest interactive_communities_login -'''.split() +""".split() LIVE_OAUTH_AUTH_SERVICE = "https://login.live.com/oauth20_authorize.srf" LIVE_OAUTH_TOKEN_SERVICE = "https://login.live.com/oauth20_token.srf" LIVE_OAUTH_DESKTOP_ENDPOINT = "https://login.live.com/oauth20_desktop.srf" -LIVE_AUTH_SCOPES = ['wl.emails', 'wl.signin'] -WWT_CLIENT_ID = '000000004015657B' -OAUTH_STATE_BASENAME = 'communities-oauth.json' -CLIENT_SECRET_BASENAME = 'communities-client-secret.txt' +LIVE_AUTH_SCOPES = ["wl.emails", "wl.signin"] +WWT_CLIENT_ID = "000000004015657B" +OAUTH_STATE_BASENAME = "communities-oauth.json" +CLIENT_SECRET_BASENAME = "communities-client-secret.txt" + class CommunitiesClient(object): """A client for WWT Communities API requests. @@ -46,18 +47,26 @@ class CommunitiesClient(object): subsequent use. """ + _parent = None _state_dir = None _state = None _access_token = None _refresh_token = None - def __init__(self, parent_client, oauth_client_secret=None, interactive_login_if_needed=False, state_dir=None): + def __init__( + self, + parent_client, + oauth_client_secret=None, + interactive_login_if_needed=False, + state_dir=None, + ): self._parent = parent_client if state_dir is None: import appdirs - state_dir = appdirs.user_state_dir('wwt_api_client', 'AAS_WWT') + + state_dir = appdirs.user_state_dir("wwt_api_client", "AAS_WWT") self._state_dir = state_dir @@ -66,20 +75,24 @@ class CommunitiesClient(object): if oauth_client_secret is None: try: - with open(os.path.join(self._state_dir, CLIENT_SECRET_BASENAME), 'rt') as f: + with open( + os.path.join(self._state_dir, CLIENT_SECRET_BASENAME), "rt" + ) as f: oauth_client_secret = f.readline().strip() except FileNotFoundError: pass if oauth_client_secret is None: - raise Exception('cannot create CommunitiesClient: the \"oauth client secret\" ' - 'is not available to the program') + raise Exception( + 'cannot create CommunitiesClient: the "oauth client secret" ' + "is not available to the program" + ) # Try to get state from a previous OAuth flow and decide what to do # based on where we're at. try: - with open(os.path.join(self._state_dir, OAUTH_STATE_BASENAME), 'rt') as f: + with open(os.path.join(self._state_dir, OAUTH_STATE_BASENAME), "rt") as f: self._state = json.load(f) except FileNotFoundError: pass @@ -89,9 +102,9 @@ class CommunitiesClient(object): # redirect_uri's. token_service_params = { - 'client_id': WWT_CLIENT_ID, - 'client_secret': oauth_client_secret, - 'redirect_uri': LIVE_OAUTH_DESKTOP_ENDPOINT, + "client_id": WWT_CLIENT_ID, + "client_secret": oauth_client_secret, + "redirect_uri": LIVE_OAUTH_DESKTOP_ENDPOINT, } # Once set, the structure of oauth_data is : { @@ -109,16 +122,16 @@ class CommunitiesClient(object): # We have previous state -- hopefully, we only need a refresh, which # can proceed non-interactively. - token_service_params['grant_type'] = 'refresh_token' - token_service_params['refresh_token'] = self._state['refresh_token'] + token_service_params["grant_type"] = "refresh_token" + token_service_params["refresh_token"] = self._state["refresh_token"] oauth_data = requests.post( LIVE_OAUTH_TOKEN_SERVICE, - data = token_service_params, + data=token_service_params, ).json() - if 'error' in oauth_data: - if oauth_data['error'] == 'invalid_grant': + if "error" in oauth_data: + if oauth_data["error"] == "invalid_grant": # This indicates that our grant has expired. We need to # rerun the auth flow. self._state = None @@ -132,67 +145,82 @@ class CommunitiesClient(object): # programs pausing for user input on the terminal. if not interactive_login_if_needed: - raise Exception('cannot create CommunitiesClient: an interactive login is ' - 'required but unavailable right now') + raise Exception( + "cannot create CommunitiesClient: an interactive login is " + "required but unavailable right now" + ) params = { - 'client_id': WWT_CLIENT_ID, - 'scope': ' '.join(LIVE_AUTH_SCOPES), - 'redirect_uri': LIVE_OAUTH_DESKTOP_ENDPOINT, - 'response_type': 'code' + "client_id": WWT_CLIENT_ID, + "scope": " ".join(LIVE_AUTH_SCOPES), + "redirect_uri": LIVE_OAUTH_DESKTOP_ENDPOINT, + "response_type": "code", } - preq = requests.Request(url=LIVE_OAUTH_AUTH_SERVICE, params=params).prepare() + preq = requests.Request( + url=LIVE_OAUTH_AUTH_SERVICE, params=params + ).prepare() print() - print('To use the WWT Communities APIs, interactive authentication to Microsoft') - print('Live is required. Open this URL in a browser and log in:') + print( + "To use the WWT Communities APIs, interactive authentication to Microsoft" + ) + print("Live is required. Open this URL in a browser and log in:") print() print(preq.url) print() - print('When done, copy the URL *that you are redirected to* and paste it here:') - print('>> ', end='') + print( + "When done, copy the URL *that you are redirected to* and paste it here:" + ) + print(">> ", end="") redir_url = input() # should look like: # 'https://login.live.com/oauth20_desktop.srf?code=MHEXHEXHE-XHEX-HEXH-EXHE-XHEXHEXHEXHE&lc=NNNN' parsed = urlparse(redir_url) params = parse_qs(parsed.query) - code = params.get('code') + code = params.get("code") if not code: raise Exception('didn\'t get "code" parameter from response URL') - token_service_params['grant_type'] = 'authorization_code' - token_service_params['code'] = code + token_service_params["grant_type"] = "authorization_code" + token_service_params["code"] = code oauth_data = requests.post( LIVE_OAUTH_TOKEN_SERVICE, - data = token_service_params, + data=token_service_params, ).json() - if 'error' in oauth_data: + if "error" in oauth_data: raise Exception(repr(oauth_data)) # Looks like it worked! Save the results for next time. os.makedirs(self._state_dir, exist_ok=True) # Sigh, Python not making it easy to be secure ... - fd = os.open(os.path.join(self._state_dir, OAUTH_STATE_BASENAME), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - f = open(fd, 'wt') + fd = os.open( + os.path.join(self._state_dir, OAUTH_STATE_BASENAME), + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + f = open(fd, "wt") with f: json.dump(oauth_data, f) - fd = os.open(os.path.join(self._state_dir, CLIENT_SECRET_BASENAME), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - f = open(fd, 'wt') + fd = os.open( + os.path.join(self._state_dir, CLIENT_SECRET_BASENAME), + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + f = open(fd, "wt") with f: print(oauth_client_secret, file=f) # And for this time: - self._access_token = oauth_data['access_token'] - self._refresh_token = oauth_data['refresh_token'] - + self._access_token = oauth_data["access_token"] + self._refresh_token = oauth_data["refresh_token"] def create_community(self, payload=None): """Create a new community owned by the current user. @@ -211,7 +239,6 @@ class CommunitiesClient(object): req.payload = payload return req - def delete_community(self, id=None): """Delete a community. @@ -229,7 +256,6 @@ class CommunitiesClient(object): req.id = id return req - def get_community_info(self, id=None): """Get information about the specified community. @@ -247,7 +273,6 @@ class CommunitiesClient(object): req.id = id return req - def get_latest_community(self): """Get information about the most recently created WWT Communities. @@ -270,7 +295,6 @@ class CommunitiesClient(object): """ return GetLatestCommunityRequest(self) - def get_my_profile(self): """Get the logged-in user's profile information. @@ -295,12 +319,11 @@ class CommunitiesClient(object): """ return GetMyProfileRequest(self) - def get_profile_entities( - self, - entity_type = enums.EntityType.CONTENT, - current_page = 1, - page_size = 99999, + self, + entity_type=enums.EntityType.CONTENT, + current_page=1, + page_size=99999, ): """Get "entities" associated with the logged-in user's profile. @@ -337,7 +360,6 @@ class CommunitiesClient(object): req.page_size = page_size return req - def is_user_registered(self): """Query whether the logged-in Microsoft Live user is registered with the WWT Communities system. @@ -369,6 +391,7 @@ class CommunitiesAPIRequest(APIRequest): These require that the user be logged in to a Microsoft Live account. """ + _comm_client = None def __init__(self, communities_client): @@ -382,6 +405,7 @@ class CreateCommunityRequest(CommunitiesAPIRequest): The response gives the ID of the new community. """ + payload = None """The request payload is JSON resembling:: @@ -403,6 +427,7 @@ class CreateCommunityRequest(CommunitiesAPIRequest): data structure at the moment.) """ + def invalidity_reason(self): if self.payload is None: return '"payload" must be a JSON dictionary' @@ -411,18 +436,18 @@ class CreateCommunityRequest(CommunitiesAPIRequest): def make_request(self): return requests.Request( - method = 'POST', - url = self._client._api_base + '/Community/Create/New', - json = self.payload, - cookies = { - 'access_token': self._comm_client._access_token, - 'refresh_token': self._comm_client._refresh_token, + method="POST", + url=self._client._api_base + "/Community/Create/New", + json=self.payload, + cookies={ + "access_token": self._comm_client._access_token, + "refresh_token": self._comm_client._refresh_token, }, ) def _process_response(self, resp): s = json.loads(resp.text) - return s['ID'] + return s["ID"] class DeleteCommunityRequest(CommunitiesAPIRequest): @@ -431,6 +456,7 @@ class DeleteCommunityRequest(CommunitiesAPIRequest): Returns True if the community was successfully deleted, False otherwise. """ + id = None "The ID number of the community to delete" @@ -444,22 +470,22 @@ class DeleteCommunityRequest(CommunitiesAPIRequest): # The API includes a {parentId} after the community ID, but it is # unused. return requests.Request( - method = 'POST', - url = f'{self._client._api_base}/Community/Delete/{self.id}/0', - cookies = { - 'access_token': self._comm_client._access_token, - 'refresh_token': self._comm_client._refresh_token, + method="POST", + url=f"{self._client._api_base}/Community/Delete/{self.id}/0", + cookies={ + "access_token": self._comm_client._access_token, + "refresh_token": self._comm_client._refresh_token, }, ) def _process_response(self, resp): t = resp.text - if t == 'True': + if t == "True": return True - elif t == 'False': + elif t == "False": return False - raise Exception(f'unexpected response from IsUserRegistered API: {t!r}') + raise Exception(f"unexpected response from IsUserRegistered API: {t!r}") # TODO: we're not implementing the "isEdit" mode where you can update @@ -536,6 +562,7 @@ class GetCommunityInfoRequest(CommunitiesAPIRequest): } } """ + id = None "The ID number of the community to probe" @@ -547,13 +574,13 @@ class GetCommunityInfoRequest(CommunitiesAPIRequest): def make_request(self): return requests.Request( - method = 'GET', - url = f'{self._client._api_base}/Community/Detail/{self.id}', - cookies = { - 'access_token': self._comm_client._access_token, - 'refresh_token': self._comm_client._refresh_token, + method="GET", + url=f"{self._client._api_base}/Community/Detail/{self.id}", + cookies={ + "access_token": self._comm_client._access_token, + "refresh_token": self._comm_client._refresh_token, }, - headers = {'LiveUserToken': self._comm_client._access_token}, + headers={"LiveUserToken": self._comm_client._access_token}, ) def _process_response(self, resp): @@ -566,19 +593,21 @@ class GetLatestCommunityRequest(CommunitiesAPIRequest): sub-Folders corresponding to the communities. """ + def invalidity_reason(self): return None def make_request(self): return requests.Request( - method = 'GET', - url = self._client._api_base + '/Resource/Service/Browse/LatestCommunity', - headers = {'LiveUserToken': self._comm_client._access_token}, + method="GET", + url=self._client._api_base + "/Resource/Service/Browse/LatestCommunity", + headers={"LiveUserToken": self._comm_client._access_token}, ) def _process_response(self, resp): from wwt_data_formats.folder import Folder from xml.etree import ElementTree as etree + xml = etree.fromstring(resp.text) return Folder.from_xml(xml) @@ -602,19 +631,20 @@ class GetMyProfileRequest(CommunitiesAPIRequest): 'IsSubscribed': False } """ + def invalidity_reason(self): return None def make_request(self): return requests.Request( - method = 'GET', - url = self._client._api_base + '/Profile/MyProfile/Get', - headers = { - 'Accept': 'application/json, text/plain, */*', + method="GET", + url=self._client._api_base + "/Profile/MyProfile/Get", + headers={ + "Accept": "application/json, text/plain, */*", }, - cookies = { - 'access_token': self._comm_client._access_token, - 'refresh_token': self._comm_client._refresh_token, + cookies={ + "access_token": self._comm_client._access_token, + "refresh_token": self._comm_client._refresh_token, }, ) @@ -628,6 +658,7 @@ class GetProfileEntitiesRequest(CommunitiesAPIRequest): Entities include communities, folders, and content files. The response is JSON. """ + entity_type = enums.EntityType.CONTENT "What kind of entity to query. Only COMMUNITY and CONTENT are allowed." @@ -648,11 +679,11 @@ class GetProfileEntitiesRequest(CommunitiesAPIRequest): def make_request(self): return requests.Request( - method = 'GET', - url = f'{self._client._api_base}/Profile/Entities/{self.entity_type.value}/{self.current_page}/{self.page_size}', - cookies = { - 'access_token': self._comm_client._access_token, - 'refresh_token': self._comm_client._refresh_token, + method="GET", + url=f"{self._client._api_base}/Profile/Entities/{self.entity_type.value}/{self.current_page}/{self.page_size}", + cookies={ + "access_token": self._comm_client._access_token, + "refresh_token": self._comm_client._refresh_token, }, ) @@ -665,41 +696,43 @@ class IsUserRegisteredRequest(CommunitiesAPIRequest): Communities system. """ + def invalidity_reason(self): return None def make_request(self): return requests.Request( - method = 'GET', - url = self._client._api_base + '/Resource/Service/User', - headers = {'LiveUserToken': self._comm_client._access_token}, + method="GET", + url=self._client._api_base + "/Resource/Service/User", + headers={"LiveUserToken": self._comm_client._access_token}, ) def _process_response(self, resp): t = resp.text - if t == 'True': + if t == "True": return True - elif t == 'False': + elif t == "False": return False - raise Exception(f'unexpected response from IsUserRegistered API: {t!r}') + raise Exception(f"unexpected response from IsUserRegistered API: {t!r}") # Command-line utility for initializing the OAuth state. + def interactive_communities_login(args): import argparse parser = argparse.ArgumentParser() parser.add_argument( - '--secret-file', - metavar = 'PATH', - help = 'Path to a file from which to read the WWT client secret', + "--secret-file", + metavar="PATH", + help="Path to a file from which to read the WWT client secret", ) parser.add_argument( - '--secret-env', - metavar = 'ENV-VAR-NAME', - help = 'Name of an environment variable containing the WWT client secret', + "--secret-env", + metavar="ENV-VAR-NAME", + help="Name of an environment variable containing the WWT client secret", ) settings = parser.parse_args(args) @@ -712,24 +745,27 @@ def interactive_communities_login(args): elif settings.secret_env is not None: client_secret = os.environ.get(settings.secret_env) else: - print('error: the WWT \"client secret\" must be provided; ' - 'use --secret-file or --secret-env', file=sys.stderr) + print( + 'error: the WWT "client secret" must be provided; ' + "use --secret-file or --secret-env", + file=sys.stderr, + ) sys.exit(1) if not client_secret: - print('error: the WWT \"client secret\" is empty or unset', file=sys.stderr) + print('error: the WWT "client secret" is empty or unset', file=sys.stderr) sys.exit(1) # Ready to go ... CommunitiesClient( Client(), - oauth_client_secret = client_secret, - interactive_login_if_needed = True, + oauth_client_secret=client_secret, + interactive_login_if_needed=True, ) - print('OAuth flow successfully completed.') + print("OAuth flow successfully completed.") -if __name__ == '__main__': +if __name__ == "__main__": interactive_communities_login(sys.argv[1:]) diff --git a/wwt_api_client/tests/__init__.py b/wwt_api_client/tests/__init__.py index 547aba0..6e0d76f 100644 --- a/wwt_api_client/tests/__init__.py +++ b/wwt_api_client/tests/__init__.py @@ -1,6 +1,6 @@ # -*- mode: python; coding: utf-8 -*- # Copyright 2019 the .Net Foundation -# Distributed under the terms of the revised (3-clause) BSD license. +# Distributed under the MIT license """This module contains tests of the wwt_api_client package. diff --git a/wwt_api_client/tests/test_communities.py b/wwt_api_client/tests/test_communities.py index 3a766c3..55e1534 100644 --- a/wwt_api_client/tests/test_communities.py +++ b/wwt_api_client/tests/test_communities.py @@ -1,6 +1,6 @@ # -*- mode: python; coding: utf-8 -*- -# Copyright 2020 the .NET Foundation -# Distributed under the terms of the revised (3-clause) BSD license. +# Copyright 2020-2023 the .NET Foundation +# Distributed under the MIT license import json from mock import Mock @@ -21,15 +21,16 @@ def fake_request_post(url, data=None, json=None, **kwargs): if url == communities.LIVE_OAUTH_TOKEN_SERVICE: rv.json.return_value = { - 'access_token': 'fake_access_token', - 'refresh_token': 'fake_refresh_token', + "access_token": "fake_access_token", + "refresh_token": "fake_refresh_token", } else: - raise Exception(f'unexpected URL to fake requests.post(): {url}') + raise Exception(f"unexpected URL to fake requests.post(): {url}") return rv -GET_COMMUNITY_INFO_JSON_TEXT = ''' + +GET_COMMUNITY_INFO_JSON_TEXT = """ { "community": { "MemberCount": 0, @@ -96,25 +97,23 @@ GET_COMMUNITY_INFO_JSON_TEXT = ''' "IsFaulted": false } } -''' +""" -GET_LATEST_COMMUNITY_XML_TEXT = '''\ +GET_LATEST_COMMUNITY_XML_TEXT = """\ -''' +""" -GET_MY_PROFILE_JSON_TEXT = ''' +GET_MY_PROFILE_JSON_TEXT = """ { "ProfileId": 123456, "ProfileName": "Firstname Lastname", @@ -128,9 +127,9 @@ GET_MY_PROFILE_JSON_TEXT = ''' "IsCurrentUser": true, "IsSubscribed": false } -''' +""" -GET_PROFILE_ENTITIES_JSON_TEXT = ''' +GET_PROFILE_ENTITIES_JSON_TEXT = """ { "entities": [ { @@ -176,37 +175,40 @@ GET_PROFILE_ENTITIES_JSON_TEXT = ''' "TotalCount": 1 } } -''' +""" + def fake_request_session_send(request, **kwargs): rv = Mock() - if request.url == DEFAULT_API_BASE + '/Community/Create/New': + if request.url == DEFAULT_API_BASE + "/Community/Create/New": rv.text = '{"ID": 800000}' - elif request.url == DEFAULT_API_BASE + '/Community/Delete/800000/0': - rv.text = 'True' - elif request.url == DEFAULT_API_BASE + '/Community/Detail/800000': + elif request.url == DEFAULT_API_BASE + "/Community/Delete/800000/0": + rv.text = "True" + elif request.url == DEFAULT_API_BASE + "/Community/Detail/800000": rv.text = GET_COMMUNITY_INFO_JSON_TEXT - elif request.url == DEFAULT_API_BASE + '/Profile/Entities/Content/1/99999': + elif request.url == DEFAULT_API_BASE + "/Profile/Entities/Content/1/99999": rv.text = GET_PROFILE_ENTITIES_JSON_TEXT - elif request.url == DEFAULT_API_BASE + '/Profile/MyProfile/Get': + elif request.url == DEFAULT_API_BASE + "/Profile/MyProfile/Get": rv.text = GET_MY_PROFILE_JSON_TEXT - elif request.url == DEFAULT_API_BASE + '/Resource/Service/Browse/LatestCommunity': + elif request.url == DEFAULT_API_BASE + "/Resource/Service/Browse/LatestCommunity": rv.text = GET_LATEST_COMMUNITY_XML_TEXT - elif request.url == DEFAULT_API_BASE + '/Resource/Service/User': - rv.text = 'True' + elif request.url == DEFAULT_API_BASE + "/Resource/Service/User": + rv.text = "True" else: - raise Exception(f'unexpected URL to fake requests.Session.send(): {request.url}') + raise Exception( + f"unexpected URL to fake requests.Session.send(): {request.url}" + ) return rv @pytest.fixture def fake_requests(mocker): - m = mocker.patch('requests.post') + m = mocker.patch("requests.post") m.side_effect = fake_request_post - m = mocker.patch('requests.Session.send') + m = mocker.patch("requests.Session.send") m.side_effect = fake_request_session_send @@ -214,19 +216,21 @@ def fake_requests(mocker): def communities_client_cached(client, fake_requests): temp_state_dir = tempfile.mkdtemp() - with open(os.path.join(temp_state_dir, communities.CLIENT_SECRET_BASENAME), 'w') as f: - print('fake_client_secret', file=f) + with open( + os.path.join(temp_state_dir, communities.CLIENT_SECRET_BASENAME), "w" + ) as f: + print("fake_client_secret", file=f) - with open(os.path.join(temp_state_dir, communities.OAUTH_STATE_BASENAME), 'w') as f: + with open(os.path.join(temp_state_dir, communities.OAUTH_STATE_BASENAME), "w") as f: oauth_data = { - 'access_token': 'fake_access_token', - 'refresh_token': 'fake_refresh_token', + "access_token": "fake_access_token", + "refresh_token": "fake_refresh_token", } json.dump(oauth_data, f) yield CommunitiesClient( client, - state_dir = temp_state_dir, + state_dir=temp_state_dir, ) shutil.rmtree(temp_state_dir) @@ -240,14 +244,14 @@ def test_create_client_cached(communities_client_cached): def communities_client_interactive(client, fake_requests, mocker): temp_state_dir = tempfile.mkdtemp() - m = mocker.patch('builtins.input') - m.return_value = 'http://fakelogin.example.com?code=fake_code' + m = mocker.patch("builtins.input") + m.return_value = "http://fakelogin.example.com?code=fake_code" yield CommunitiesClient( client, - oauth_client_secret = 'fake_client_secret', + oauth_client_secret="fake_client_secret", interactive_login_if_needed=True, - state_dir = temp_state_dir, + state_dir=temp_state_dir, ) shutil.rmtree(temp_state_dir) @@ -258,34 +262,34 @@ def test_create_client_interactive(communities_client_interactive): def test_cli(communities_client_interactive, mocker): - m = mocker.patch('wwt_api_client.communities.CommunitiesClient') + m = mocker.patch("wwt_api_client.communities.CommunitiesClient") m.return_value = communities_client_interactive with pytest.raises(SystemExit): communities.interactive_communities_login([]) - os.environ['FAKE_CLIENT_SECRET'] = 'fakey' - communities.interactive_communities_login(['--secret-env=FAKE_CLIENT_SECRET']) + os.environ["FAKE_CLIENT_SECRET"] = "fakey" + communities.interactive_communities_login(["--secret-env=FAKE_CLIENT_SECRET"]) - f = tempfile.NamedTemporaryFile(mode='wt', delete=False) - print('fake_client_secret', file=f) + f = tempfile.NamedTemporaryFile(mode="wt", delete=False) + print("fake_client_secret", file=f) f.close() - communities.interactive_communities_login([f'--secret-file={f.name}']) + communities.interactive_communities_login([f"--secret-file={f.name}"]) os.unlink(f.name) def test_create_community(communities_client_cached): payload = { - 'communityJson': { - 'CategoryID': 20, - 'ParentID': '610131', - 'AccessTypeID': 2, - 'IsOffensive': False, - 'IsLink': False, - 'CommunityType': 'Community', - 'Name': 'API Test Community', - 'Description': 'Community description', - 'Tags': 'tag1,tag2' + "communityJson": { + "CategoryID": 20, + "ParentID": "610131", + "AccessTypeID": 2, + "IsOffensive": False, + "IsLink": False, + "CommunityType": "Community", + "Name": "API Test Community", + "Description": "Community description", + "Tags": "tag1,tag2", } } new_id = communities_client_cached.create_community(payload=payload).send() @@ -319,13 +323,16 @@ def test_get_my_profile(communities_client_cached): def test_get_profile_entities(communities_client_cached): expected_json = json.loads(GET_PROFILE_ENTITIES_JSON_TEXT) observed_json = communities_client_cached.get_profile_entities( - entity_type = enums.EntityType.CONTENT, - current_page = 1, - page_size = 99999, + entity_type=enums.EntityType.CONTENT, + current_page=1, + page_size=99999, ).send() assert observed_json == expected_json def test_is_user_registered(communities_client_cached): - assert communities_client_cached.is_user_registered().send(raw_response=True).text == 'True' + assert ( + communities_client_cached.is_user_registered().send(raw_response=True).text + == "True" + ) assert communities_client_cached.is_user_registered().send() diff --git a/wwt_api_client/tests/test_core.py b/wwt_api_client/tests/test_core.py index 04e630e..0eb25dc 100644 --- a/wwt_api_client/tests/test_core.py +++ b/wwt_api_client/tests/test_core.py @@ -1,6 +1,6 @@ # -*- mode: python; coding: utf-8 -*- -# Copyright 2019 the .Net Foundation -# Distributed under the terms of the revised (3-clause) BSD license. +# Copyright 2019-2023 the .Net Foundation +# Distributed under the MIT license """Note! This test suite will hit the network! @@ -11,137 +11,157 @@ from xml.etree import ElementTree from .. import Client -INF = float('inf') -NAN = float('nan') +INF = float("inf") +NAN = float("nan") + def _assert_xml_trees_equal(path, e1, e2, care_text_tags): "Derived from https://stackoverflow.com/a/24349916/3760486" - assert e1.tag == e2.tag, \ - 'at XML path {0}, tags {1} and {2} differed'.format(path, e1.tag, e2.tag) + assert e1.tag == e2.tag, "at XML path {0}, tags {1} and {2} differed".format( + path, e1.tag, e2.tag + ) # We only sometimes care about this; often it's just whitespace if e1.tag in care_text_tags: - assert e1.text == e2.text, \ - 'at XML path {0}, texts {1!r} and {2!r} differed'.format(path, e1.text, e2.text) + assert ( + e1.text == e2.text + ), "at XML path {0}, texts {1!r} and {2!r} differed".format( + path, e1.text, e2.text + ) # We never care about this, right? - #assert e1.tail == e2.tail, \ + # assert e1.tail == e2.tail, \ # 'at XML path {0}, tails {1!r} and {2!r} differed'.format(path, e1.tail, e2.tail) - assert e1.attrib == e2.attrib, \ - 'at XML path {0}, attributes {1!r} and {2!r} differed'.format(path, e1.attrib, e2.attrib) - assert len(e1) == len(e2), \ - 'at XML path {0}, number of children {1} and {2} differed'.format(path, len(e1), len(e2)) + assert ( + e1.attrib == e2.attrib + ), "at XML path {0}, attributes {1!r} and {2!r} differed".format( + path, e1.attrib, e2.attrib + ) + assert len(e1) == len( + e2 + ), "at XML path {0}, number of children {1} and {2} differed".format( + path, len(e1), len(e2) + ) - subpath = '{0}>{1}'.format(path, e1.tag) + subpath = "{0}>{1}".format(path, e1.tag) - for c1, c2 in zip (e1, e2): + for c1, c2 in zip(e1, e2): _assert_xml_trees_equal(subpath, c1, c2, care_text_tags) + def assert_xml_trees_equal(e1, e2, care_text_tags=()): - _assert_xml_trees_equal('(root)', e1, e2, care_text_tags) + _assert_xml_trees_equal("(root)", e1, e2, care_text_tags) @pytest.fixture def client(): return Client() + @pytest.fixture def login(client): "Return a valid login request object." return client.login() + def test_login_basic(login): assert login.invalidity_reason() is None login.send() + @pytest.fixture def showimage(client): "Return a valid ShowImage request object." - return client.show_image('http://localhost/image.jpg', 'name') + return client.show_image("http://localhost/image.jpg", "name") + SHOWIMAGE_BAD_SETTINGS = [ - ('credits', b'\xff not unicodable'), - ('credits_url', u'http://olé/not_ascii_unicode_url'), - ('credits_url', b'http://host/\x81/not_ascii_bytes_url'), - ('credits_url', 'not_absolute_url'), - ('dec_deg', -90.00001), - ('dec_deg', 90.00001), - ('dec_deg', NAN), - ('dec_deg', INF), - ('dec_deg', 'not numeric'), - ('image_url', None), - ('image_url', u'http://olé/not_ascii_unicode_url'), - ('image_url', b'http://host/\x81/not_ascii_bytes_url'), - ('image_url', 'not_absolute_url'), - ('name', None), - ('ra_deg', NAN), - ('ra_deg', INF), - ('ra_deg', 'not numeric'), - ('reverse_parity', 1), # only bools allowed - ('reverse_parity', 't'), # only bools allowed - ('rotation_deg', NAN), - ('rotation_deg', INF), - ('rotation_deg', 'not numeric'), - ('scale_arcsec', 0.), - ('scale_arcsec', NAN), - ('scale_arcsec', INF), - ('scale_arcsec', 'not numeric'), - ('thumbnail_url', u'http://olé/not_ascii_unicode_url'), - ('thumbnail_url', b'http://host/\x81/not_ascii_bytes_url'), - ('thumbnail_url', 'not_absolute_url'), - ('x_offset_pixels', NAN), - ('x_offset_pixels', INF), - ('x_offset_pixels', 'not numeric'), - ('y_offset_pixels', NAN), - ('y_offset_pixels', INF), - ('y_offset_pixels', 'not numeric'), + ("credits", b"\xff not unicodable"), + ("credits_url", "http://olé/not_ascii_unicode_url"), + ("credits_url", b"http://host/\x81/not_ascii_bytes_url"), + ("credits_url", "not_absolute_url"), + ("dec_deg", -90.00001), + ("dec_deg", 90.00001), + ("dec_deg", NAN), + ("dec_deg", INF), + ("dec_deg", "not numeric"), + ("image_url", None), + ("image_url", "http://olé/not_ascii_unicode_url"), + ("image_url", b"http://host/\x81/not_ascii_bytes_url"), + ("image_url", "not_absolute_url"), + ("name", None), + ("ra_deg", NAN), + ("ra_deg", INF), + ("ra_deg", "not numeric"), + ("reverse_parity", 1), # only bools allowed + ("reverse_parity", "t"), # only bools allowed + ("rotation_deg", NAN), + ("rotation_deg", INF), + ("rotation_deg", "not numeric"), + ("scale_arcsec", 0.0), + ("scale_arcsec", NAN), + ("scale_arcsec", INF), + ("scale_arcsec", "not numeric"), + ("thumbnail_url", "http://olé/not_ascii_unicode_url"), + ("thumbnail_url", b"http://host/\x81/not_ascii_bytes_url"), + ("thumbnail_url", "not_absolute_url"), + ("x_offset_pixels", NAN), + ("x_offset_pixels", INF), + ("x_offset_pixels", "not numeric"), + ("y_offset_pixels", NAN), + ("y_offset_pixels", INF), + ("y_offset_pixels", "not numeric"), ] -@pytest.mark.parametrize(('attr', 'val'), SHOWIMAGE_BAD_SETTINGS) + +@pytest.mark.parametrize(("attr", "val"), SHOWIMAGE_BAD_SETTINGS) def test_showimage_invalid_settings(showimage, attr, val): setattr(showimage, attr, val) assert showimage.invalidity_reason() is not None + SHOWIMAGE_GOOD_SETTINGS = [ - ('credits', b'unicodable bytes'), - ('credits', u'unicode é'), - ('credits', None), - ('credits_url', b'http://localhost/absolute_bytes_url'), - ('credits_url', u'//localhost/absolute_unicode_url'), - ('credits_url', None), - ('dec_deg', -90), - ('dec_deg', 90), - ('image_url', b'http://localhost/absolute_bytes_url'), - ('image_url', u'//localhost/absolute_unicode_url'), - ('name', b'unicodable bytes'), - ('name', u'unicode é'), - ('ra_deg', -720.), - ('ra_deg', 980.), - ('reverse_parity', False), - ('reverse_parity', True), - ('rotation_deg', -1), - ('scale_arcsec', -1.), - ('thumbnail_url', b'http://localhost/absolute_bytes_url'), - ('thumbnail_url', u'//localhost/absolute_unicode_url'), - ('thumbnail_url', None), - ('x_offset_pixels', -1.), - ('x_offset_pixels', 0), - ('y_offset_pixels', -1.), - ('y_offset_pixels', 0), + ("credits", b"unicodable bytes"), + ("credits", "unicode é"), + ("credits", None), + ("credits_url", b"http://localhost/absolute_bytes_url"), + ("credits_url", "//localhost/absolute_unicode_url"), + ("credits_url", None), + ("dec_deg", -90), + ("dec_deg", 90), + ("image_url", b"http://localhost/absolute_bytes_url"), + ("image_url", "//localhost/absolute_unicode_url"), + ("name", b"unicodable bytes"), + ("name", "unicode é"), + ("ra_deg", -720.0), + ("ra_deg", 980.0), + ("reverse_parity", False), + ("reverse_parity", True), + ("rotation_deg", -1), + ("scale_arcsec", -1.0), + ("thumbnail_url", b"http://localhost/absolute_bytes_url"), + ("thumbnail_url", "//localhost/absolute_unicode_url"), + ("thumbnail_url", None), + ("x_offset_pixels", -1.0), + ("x_offset_pixels", 0), + ("y_offset_pixels", -1.0), + ("y_offset_pixels", 0), ] -@pytest.mark.parametrize(('attr', 'val'), SHOWIMAGE_GOOD_SETTINGS) + +@pytest.mark.parametrize(("attr", "val"), SHOWIMAGE_GOOD_SETTINGS) def test_showimage_valid_settings(showimage, attr, val): setattr(showimage, attr, val) assert showimage.invalidity_reason() is None -SHOWIMAGE_CARE_TEXT_TAGS = set(('Credits', 'CreditsUrl')) +SHOWIMAGE_CARE_TEXT_TAGS = set(("Credits", "CreditsUrl")) -def _make_showimage_result(credurl='', name='name'): - return ''' + +def _make_showimage_result(credurl="", name="name"): + return """ @@ -149,22 +169,29 @@ def _make_showimage_result(credurl='', name='name'): + OffsetY="0" BaseTileLevel="0" BaseDegreesPerTile="0.0002777777777777778"> {credurl} -'''.format(credurl=credurl, name=name) +""".format( + credurl=credurl, name=name + ) + SHOWIMAGE_RESULTS = [ (dict(), _make_showimage_result()), - (dict(name='test&xml"esc'), _make_showimage_result(name='test&xml"esc')), - (dict(credits_url='http://a/b&c'), _make_showimage_result(credurl='http://a/b&c')), + (dict(name='test&xml"esc'), _make_showimage_result(name="test&xml"esc")), + ( + dict(credits_url="http://a/b&c"), + _make_showimage_result(credurl="http://a/b&c"), + ), ] -@pytest.mark.parametrize(('attrs', 'expected'), SHOWIMAGE_RESULTS) + +@pytest.mark.parametrize(("attrs", "expected"), SHOWIMAGE_RESULTS) def test_showimage_valid_settings(showimage, attrs, expected): expected = ElementTree.fromstring(expected) @@ -179,88 +206,95 @@ def test_showimage_valid_settings(showimage, attrs, expected): @pytest.fixture def tileimage(client): "Return a valid TileImage request object." - return client.tile_image('http://www.spitzer.caltech.edu/uploaded_files/images/0009/0848/sig12-011.jpg') + return client.tile_image( + "http://www.spitzer.caltech.edu/uploaded_files/images/0009/0848/sig12-011.jpg" + ) + TILEIMAGE_BAD_SETTINGS = [ - ('credits', b'\xff not unicodable'), - ('credits_url', u'http://olé/not_ascii_unicode_url'), - ('credits_url', b'http://host/\x81/not_ascii_bytes_url'), - ('credits_url', 'not_absolute_url'), - ('dec_deg', -90.00001), - ('dec_deg', 90.00001), - ('dec_deg', NAN), - ('dec_deg', INF), - ('dec_deg', 'not numeric'), - ('image_url', None), - ('image_url', u'http://olé/not_ascii_unicode_url'), - ('image_url', b'http://host/\x81/not_ascii_bytes_url'), - ('image_url', 'not_absolute_url'), - ('ra_deg', NAN), - ('ra_deg', INF), - ('ra_deg', 'not numeric'), - ('rotation_deg', NAN), - ('rotation_deg', INF), - ('rotation_deg', 'not numeric'), - ('scale_deg', 0.), - ('scale_deg', NAN), - ('scale_deg', INF), - ('scale_deg', 'not numeric'), - ('thumbnail_url', u'http://olé/not_ascii_unicode_url'), - ('thumbnail_url', b'http://host/\x81/not_ascii_bytes_url'), - ('thumbnail_url', 'not_absolute_url'), - ('x_offset_deg', NAN), - ('x_offset_deg', INF), - ('x_offset_deg', 'not numeric'), - ('y_offset_deg', NAN), - ('y_offset_deg', INF), - ('y_offset_deg', 'not numeric'), + ("credits", b"\xff not unicodable"), + ("credits_url", "http://olé/not_ascii_unicode_url"), + ("credits_url", b"http://host/\x81/not_ascii_bytes_url"), + ("credits_url", "not_absolute_url"), + ("dec_deg", -90.00001), + ("dec_deg", 90.00001), + ("dec_deg", NAN), + ("dec_deg", INF), + ("dec_deg", "not numeric"), + ("image_url", None), + ("image_url", "http://olé/not_ascii_unicode_url"), + ("image_url", b"http://host/\x81/not_ascii_bytes_url"), + ("image_url", "not_absolute_url"), + ("ra_deg", NAN), + ("ra_deg", INF), + ("ra_deg", "not numeric"), + ("rotation_deg", NAN), + ("rotation_deg", INF), + ("rotation_deg", "not numeric"), + ("scale_deg", 0.0), + ("scale_deg", NAN), + ("scale_deg", INF), + ("scale_deg", "not numeric"), + ("thumbnail_url", "http://olé/not_ascii_unicode_url"), + ("thumbnail_url", b"http://host/\x81/not_ascii_bytes_url"), + ("thumbnail_url", "not_absolute_url"), + ("x_offset_deg", NAN), + ("x_offset_deg", INF), + ("x_offset_deg", "not numeric"), + ("y_offset_deg", NAN), + ("y_offset_deg", INF), + ("y_offset_deg", "not numeric"), ] -@pytest.mark.parametrize(('attr', 'val'), TILEIMAGE_BAD_SETTINGS) + +@pytest.mark.parametrize(("attr", "val"), TILEIMAGE_BAD_SETTINGS) def test_tileimage_invalid_settings(tileimage, attr, val): setattr(tileimage, attr, val) assert tileimage.invalidity_reason() is not None + TILEIMAGE_GOOD_SETTINGS = [ - ('credits', b'unicodable bytes'), - ('credits', u'unicode é'), - ('credits', None), - ('credits_url', b'http://localhost/absolute_bytes_url'), - ('credits_url', u'//localhost/absolute_unicode_url'), - ('credits_url', None), - ('dec_deg', 90), - ('dec_deg', 90), - ('dec_deg', None), - ('image_url', b'http://localhost/absolute_bytes_url'), - ('image_url', u'//localhost/absolute_unicode_url'), - ('ra_deg', -720.), - ('ra_deg', 980.), - ('ra_deg', None), - ('rotation_deg', -1), - ('rotation_deg', None), - ('scale_deg', -1.), - ('scale_deg', None), - ('thumbnail_url', b'http://localhost/absolute_bytes_url'), - ('thumbnail_url', u'//localhost/absolute_unicode_url'), - ('thumbnail_url', None), - ('x_offset_deg', -1.), - ('x_offset_deg', 0), - ('x_offset_deg', None), - ('y_offset_deg', -1.), - ('y_offset_deg', 0), - ('y_offset_deg', None), + ("credits", b"unicodable bytes"), + ("credits", "unicode é"), + ("credits", None), + ("credits_url", b"http://localhost/absolute_bytes_url"), + ("credits_url", "//localhost/absolute_unicode_url"), + ("credits_url", None), + ("dec_deg", 90), + ("dec_deg", 90), + ("dec_deg", None), + ("image_url", b"http://localhost/absolute_bytes_url"), + ("image_url", "//localhost/absolute_unicode_url"), + ("ra_deg", -720.0), + ("ra_deg", 980.0), + ("ra_deg", None), + ("rotation_deg", -1), + ("rotation_deg", None), + ("scale_deg", -1.0), + ("scale_deg", None), + ("thumbnail_url", b"http://localhost/absolute_bytes_url"), + ("thumbnail_url", "//localhost/absolute_unicode_url"), + ("thumbnail_url", None), + ("x_offset_deg", -1.0), + ("x_offset_deg", 0), + ("x_offset_deg", None), + ("y_offset_deg", -1.0), + ("y_offset_deg", 0), + ("y_offset_deg", None), ] -@pytest.mark.parametrize(('attr', 'val'), TILEIMAGE_GOOD_SETTINGS) + +@pytest.mark.parametrize(("attr", "val"), TILEIMAGE_GOOD_SETTINGS) def test_tileimage_valid_settings(tileimage, attr, val): setattr(tileimage, attr, val) assert tileimage.invalidity_reason() is None -TILEIMAGE_CARE_TEXT_TAGS = set(('Credits', 'CreditsUrl')) +TILEIMAGE_CARE_TEXT_TAGS = set(("Credits", "CreditsUrl")) -def _make_tileimage_result(credits='', credurl='', ident=None, name='Image File'): - return ''' + +def _make_tileimage_result(credits="", credurl="", ident=None, name="Image File"): + return """ @@ -277,18 +311,25 @@ def _make_tileimage_result(credits='', credurl='', ident=None, name='Image File' -'''.format(credits=credits, credurl=credurl, ident=ident, name=name) +""".format( + credits=credits, credurl=credurl, ident=ident, name=name + ) + TILEIMAGE_RESULTS = [ - (dict(), _make_tileimage_result( - credits = ' NASA/JPL-Caltech', - credurl = 'http://www.spitzer.caltech.edu/images/5259-sig12-011-The-Helix-Nebula-Unraveling-at-the-Seams', - ident = '1176481368', - name = 'Helix Nebula', - )), + ( + dict(), + _make_tileimage_result( + credits=" NASA/JPL-Caltech", + credurl="http://www.spitzer.caltech.edu/images/5259-sig12-011-The-Helix-Nebula-Unraveling-at-the-Seams", + ident="1176481368", + name="Helix Nebula", + ), + ), ] -@pytest.mark.parametrize(('attrs', 'expected'), TILEIMAGE_RESULTS) + +@pytest.mark.parametrize(("attrs", "expected"), TILEIMAGE_RESULTS) def test_tileimage_valid_settings(tileimage, attrs, expected): expected = ElementTree.fromstring(expected)