Merge pull request #10 from pkgw/deep-six

Fix up tests/build
This commit is contained in:
Peter Williams 2023-03-29 17:43:51 +00:00 коммит произвёл GitHub
Родитель ca7d8ca91a f8e3182e11
Коммит 397c9a8f3f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
19 изменённых файлов: 731 добавлений и 485 удалений

34
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.

Просмотреть файл

@ -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

Просмотреть файл

@ -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

Просмотреть файл

@ -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:

Просмотреть файл

@ -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

Просмотреть файл

@ -0,0 +1,3 @@
.. automodapi:: wwt_api_client.communities
:no-inheritance-diagram:
:inherited-members:

Просмотреть файл

@ -0,0 +1,3 @@
.. automodapi:: wwt_api_client.enums
:no-inheritance-diagram:
:inherited-members:

Просмотреть файл

@ -0,0 +1,3 @@
.. automodapi:: wwt_api_client
:no-inheritance-diagram:
:inherited-members:

Просмотреть файл

@ -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

Просмотреть файл

@ -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

Просмотреть файл

@ -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 <http://worldwidetelescope.org/home>`_ is a free
AAS `WorldWide Telescope <http://worldwidetelescope.org/home>`_ is a free
and powerful visualization engine developed by the `American Astronomical
Society <https://aas.org/>`_ 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
Societys support of the WorldWide Telescope project.

Просмотреть файл

@ -1,8 +1,8 @@
build:
image: latest
image: latest
python:
version: 3.7
version: 3.10
pip_install: true
extra_requirements: ['docs']

Просмотреть файл

@ -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={},

Просмотреть файл

@ -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 <endpoint-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 <endpoint-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 <endpoint-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, {'"': '&quot;'})
text = xml_escape(text, {'"': "&quot;"})
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-ShowImage>` 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-TileImage>` 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,
)

Просмотреть файл

@ -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)

Просмотреть файл

@ -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:])

Просмотреть файл

@ -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.

Просмотреть файл

@ -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 = """\
<?xml version='1.0' encoding='UTF-8'?>
<Folder Browseable="True" Group="Explorer"
MSRCommunityId="0" MSRComponentId="0" Permission="0"
Searchable="True"
Thumbnail="http://www.worldwidetelescope.org/Content/Images/defaultfolderwwtthumbnail.png"
Type="Earth">
<Folder Browseable="True" Group="Explorer" Name="AAS Nova"
MSRCommunityId="0" MSRComponentId="0" Permission="0"
Searchable="True"
Thumbnail="http://www.worldwidetelescope.org/File/Thumbnail/80a8d8ef-8a76-414a-a398-349337baac8c"
Url="http://www.worldwidetelescope.org/Resource/Service/Folder/607649"
Type="Earth" />
</Folder>
'''
"""
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()

Просмотреть файл

@ -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 '''<?xml version="1.0" encoding="UTF-8"?>
def _make_showimage_result(credurl="", name="name"):
return """<?xml version="1.0" encoding="UTF-8"?>
<Folder Name="{name}" Group="Goto">
<Place Name="{name}" RA="0" Dec="0" ZoomLevel="0" DataSetType="Sky" Opacity="100"
Thumbnail="" Constellation="">
@ -149,22 +169,29 @@ def _make_showimage_result(credurl='', name='name'):
<ImageSet DataSetType="Sky" BandPass="Visible" Url="http://localhost/image.jpg"
TileLevels="0" WidthFactor="2" Rotation="0" Projection="SkyImage"
FileType=".tif" CenterY="0" CenterX="0" BottomsUp="False" OffsetX="0"
OffsetY="0" BaseTileLevel="0" BaseDegreesPerTile="0.000277777777777778">
OffsetY="0" BaseTileLevel="0" BaseDegreesPerTile="0.0002777777777777778">
<Credits></Credits>
<CreditsUrl>{credurl}</CreditsUrl>
</ImageSet>
</ForegroundImageSet>
</Place>
</Folder>
'''.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&amp;xml&quot;esc')),
(dict(credits_url='http://a/b&c'), _make_showimage_result(credurl='http://a/b&amp;c')),
(dict(name='test&xml"esc'), _make_showimage_result(name="test&amp;xml&quot;esc")),
(
dict(credits_url="http://a/b&c"),
_make_showimage_result(credurl="http://a/b&amp;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 '''<Folder Name="{name}" Group="Explorer">
def _make_tileimage_result(credits="", credurl="", ident=None, name="Image File"):
return """<Folder Name="{name}" Group="Explorer">
<Place Name="{name}" RA="0" Dec="0" ZoomLevel="32768" DataSetType="Sky"
Opacity="100" Thumbnail="http://www.worldwidetelescope.org/wwtweb/tilethumb.aspx?name={ident}"
Constellation="">
@ -277,18 +311,25 @@ def _make_tileimage_result(credits='', credurl='', ident=None, name='Image File'
</ForegroundImageSet>
</Place>
</Folder>
'''.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)