Коммит
397c9a8f3f
34
LICENSE
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
|
||||
|
||||
|
|
14
docs/api.rst
14
docs/api.rst
|
@ -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:
|
51
docs/conf.py
51
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
|
||||
|
|
|
@ -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
|
||||
Society’s support of the WorldWide Telescope project.
|
||||
|
|
|
@ -2,7 +2,7 @@ build:
|
|||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.7
|
||||
version: 3.10
|
||||
pip_install: true
|
||||
extra_requirements: ['docs']
|
||||
|
||||
|
|
15
setup.py
15
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={},
|
||||
|
|
|
@ -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, {'"': '"'})
|
||||
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-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,
|
||||
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&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 '''<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)
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче