Коммит
397c9a8f3f
34
LICENSE
34
LICENSE
|
@ -1,24 +1,18 @@
|
||||||
The MIT License
|
The MIT License
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
modification, are permitted provided that the following conditions are met:
|
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
|
The above copyright notice and this permission notice shall be included in all
|
||||||
list of conditions and the following disclaimer.
|
copies or substantial portions of the Software.
|
||||||
* 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.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
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.
|
|
||||||
|
|
|
@ -62,6 +62,7 @@ jobs:
|
||||||
source activate-conda.sh
|
source activate-conda.sh
|
||||||
conda activate build
|
conda activate build
|
||||||
set -x
|
set -x
|
||||||
|
\conda install -y httpretty mock pytest pytest-cov pytest-mock
|
||||||
pytest wwt_api_client
|
pytest wwt_api_client
|
||||||
displayName: Test
|
displayName: Test
|
||||||
|
|
||||||
|
@ -87,7 +88,7 @@ jobs:
|
||||||
source activate-conda.sh
|
source activate-conda.sh
|
||||||
conda activate build
|
conda activate build
|
||||||
set -x
|
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
|
pytest --cov-report=xml --cov=wwt_api_client wwt_api_client
|
||||||
displayName: Test with coverage
|
displayName: Test with coverage
|
||||||
|
|
||||||
|
|
|
@ -66,7 +66,8 @@ jobs:
|
||||||
conda activate
|
conda activate
|
||||||
conda config --add channels conda-forge
|
conda config --add channels conda-forge
|
||||||
conda install -y \
|
conda install -y \
|
||||||
requests
|
requests \
|
||||||
|
wwt_data_formats
|
||||||
pip install openidc_client
|
pip install openidc_client
|
||||||
displayName: Set up dependencies
|
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"
|
project = "wwt_api_client"
|
||||||
author = "WorldWide Telescope project"
|
author = "WorldWide Telescope project"
|
||||||
copyright = "2019-2023 " + author
|
copyright = "2019-2023 " + author
|
||||||
|
|
||||||
release = "0.dev0" # cranko project-version
|
release = "0.dev0" # cranko project-version
|
||||||
|
version = ".".join(release.split(".")[:2])
|
||||||
|
|
||||||
extensions = [
|
extensions = [
|
||||||
"sphinx.ext.autodoc",
|
"sphinx.ext.autodoc",
|
||||||
|
@ -17,12 +18,52 @@ extensions = [
|
||||||
"numpydoc",
|
"numpydoc",
|
||||||
]
|
]
|
||||||
|
|
||||||
master_doc = "index"
|
|
||||||
templates_path = ["_templates"]
|
templates_path = ["_templates"]
|
||||||
|
source_suffix = ".rst"
|
||||||
|
master_doc = "index"
|
||||||
|
language = "en"
|
||||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||||
|
pygments_style = "sphinx"
|
||||||
|
todo_include_todos = False
|
||||||
|
|
||||||
numpydoc_class_members_toctree = False
|
html_theme = "bootstrap-astropy"
|
||||||
|
html_theme_options = {
|
||||||
html_theme = "alabaster"
|
"logotext1": "wwt_api_client",
|
||||||
|
"logotext2": "",
|
||||||
|
"logotext3": ":docs",
|
||||||
|
"astropy_project_menubar": False,
|
||||||
|
}
|
||||||
html_static_path = ["_static"]
|
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"
|
html_logo = "images/logo.png"
|
||||||
|
|
||||||
|
linkcheck_retries = 5
|
||||||
|
linkcheck_timeout = 10
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
.. _endpoint-Login:
|
.. _endpoint-Login:
|
||||||
|
|
||||||
``wwtweb/login.aspx``
|
``WWTWeb/Login.aspx``
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
Windows clients invoke this API when they boot up. They send advisory version
|
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
|
and powerful visualization engine developed by the `American Astronomical
|
||||||
Society <https://aas.org/>`_ that can display astronomical and planetary data.
|
Society <https://aas.org/>`_ that can display astronomical and planetary data.
|
||||||
This engine is powered by a large (multi-terabyte) collection of astronomical
|
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.
|
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::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
installation
|
installation
|
||||||
endpoints/index.rst
|
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
|
Getting help
|
||||||
------------
|
============
|
||||||
|
|
||||||
If you run into any issues when using ``wwt_api_client``, please open an issue
|
If you run into any issues when using ``wwt_api_client``, please open an issue
|
||||||
`on its GitHub repository
|
`on its GitHub repository
|
||||||
|
@ -29,7 +42,21 @@ If you run into any issues when using ``wwt_api_client``, please open an issue
|
||||||
|
|
||||||
|
|
||||||
Acknowledgments
|
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.
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
build:
|
build:
|
||||||
image: latest
|
image: latest
|
||||||
|
|
||||||
python:
|
python:
|
||||||
version: 3.7
|
version: 3.10
|
||||||
pip_install: true
|
pip_install: true
|
||||||
extra_requirements: ['docs']
|
extra_requirements: ['docs']
|
||||||
|
|
||||||
|
|
15
setup.py
15
setup.py
|
@ -1,7 +1,6 @@
|
||||||
#! /usr/bin/env python
|
#! /usr/bin/env python
|
||||||
# -*- mode: python; coding: utf-8 -*-
|
# Copyright 2019-2023 the .NET Foundation
|
||||||
# Copyright 2019-2020 the .NET Foundation
|
# Distributed under the MIT license
|
||||||
# Distributed under the terms of the revised (3-clause) BSD license.
|
|
||||||
|
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
|
@ -52,7 +51,7 @@ setup_args = dict(
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"Intended Audience :: Science/Research",
|
"Intended Audience :: Science/Research",
|
||||||
"License :: OSI Approved :: BSD License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Topic :: Multimedia :: Graphics",
|
"Topic :: Multimedia :: Graphics",
|
||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: 3.7",
|
"Programming Language :: Python :: 3.7",
|
||||||
|
@ -64,19 +63,21 @@ setup_args = dict(
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"requests",
|
"requests",
|
||||||
|
"wwt_data_formats",
|
||||||
],
|
],
|
||||||
extras_require={
|
extras_require={
|
||||||
"test": [
|
"test": [
|
||||||
"httpretty",
|
"httpretty",
|
||||||
|
"mock",
|
||||||
"pytest",
|
"pytest",
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
"pytest-mock",
|
"pytest-mock",
|
||||||
],
|
],
|
||||||
"docs": [
|
"docs": [
|
||||||
"sphinx>=1.6",
|
"astropy-sphinx-theme",
|
||||||
"sphinx-automodapi",
|
|
||||||
"numpydoc",
|
"numpydoc",
|
||||||
"sphinx_rtd_theme",
|
"sphinx",
|
||||||
|
"sphinx-automodapi",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
entry_points={},
|
entry_points={},
|
||||||
|
|
|
@ -1,36 +1,29 @@
|
||||||
# -*- mode: python; coding: utf-8 -*-
|
# -*- mode: python; coding: utf-8 -*-
|
||||||
# Copyright 2019-2020 the .Net Foundation
|
# Copyright 2019-2023 the .Net Foundation
|
||||||
# Distributed under the terms of the revised (3-clause) BSD license.
|
# Distributed under the MIT license
|
||||||
|
|
||||||
from __future__ import absolute_import, division, print_function
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import six
|
from urllib import parse as url_parse
|
||||||
from six.moves.urllib import parse as url_parse
|
|
||||||
from xml.sax.saxutils import escape as xml_escape
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from ._version import version_info, __version__ # noqa
|
__all__ = """
|
||||||
|
|
||||||
__all__ = '''
|
|
||||||
__version__
|
|
||||||
APIRequest
|
APIRequest
|
||||||
APIResponseError
|
APIResponseError
|
||||||
Client
|
Client
|
||||||
DEFAULT_API_BASE
|
DEFAULT_API_BASE
|
||||||
|
LoginRequest
|
||||||
InvalidRequestError
|
InvalidRequestError
|
||||||
ShowImageRequest
|
ShowImageRequest
|
||||||
TileImageRequest
|
TileImageRequest
|
||||||
version_info
|
""".split()
|
||||||
'''.split()
|
|
||||||
|
|
||||||
DEFAULT_API_BASE = 'http://www.worldwidetelescope.org'
|
DEFAULT_API_BASE = "http://www.worldwidetelescope.org"
|
||||||
|
|
||||||
|
|
||||||
class APIResponseError(Exception):
|
class APIResponseError(Exception):
|
||||||
"""Raised when the API returns an HTTP error.
|
"""Raised when the API returns an HTTP error."""
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
|
@ -39,9 +32,8 @@ class APIResponseError(Exception):
|
||||||
|
|
||||||
|
|
||||||
class InvalidRequestError(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):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
|
@ -58,12 +50,13 @@ class Client(object):
|
||||||
----------
|
----------
|
||||||
api_base : URL string or None
|
api_base : URL string or None
|
||||||
The base URL to use for accessing the WWT web APIs. Defaults to
|
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
|
"http://www.worldwidetelescope.org". The API base is configurable to
|
||||||
make it possible to access testing servers, etc. This value should not
|
make it possible to access testing servers, etc. This value should not
|
||||||
end in a slash.
|
end in a slash.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_api_base = None
|
_api_base = None
|
||||||
_session = None
|
_session = None
|
||||||
|
|
||||||
|
@ -81,8 +74,12 @@ class Client(object):
|
||||||
|
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
def login(self, user_guid='00000000-0000-0000-0000-000000000000', client_version='6.0.0.0',
|
def login(
|
||||||
equinox_version_or_later=True):
|
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.
|
"""Create a :ref:`Login <endpoint-Login>` request object.
|
||||||
|
|
||||||
Parameters are assigned to attributes of the return value; see
|
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
|
req.equinox_version_or_later = equinox_version_or_later
|
||||||
return req
|
return req
|
||||||
|
|
||||||
def show_image(self, image_url=None, name=None, credits=None, credits_url=None,
|
def show_image(
|
||||||
dec_deg=0.0, ra_deg=0.0, reverse_parity=False, rotation_deg=0.0,
|
self,
|
||||||
scale_arcsec=1.0, thumbnail_url=None, x_offset_pixels=0.0, y_offset_pixels=0.0):
|
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.
|
"""Create a :ref:`ShowImage <endpoint-ShowImage>` request object.
|
||||||
|
|
||||||
Parameters are assigned to attributes of the return value; see
|
Parameters are assigned to attributes of the return value; see
|
||||||
|
@ -147,9 +156,19 @@ class Client(object):
|
||||||
req.y_offset_pixels = y_offset_pixels
|
req.y_offset_pixels = y_offset_pixels
|
||||||
return req
|
return req
|
||||||
|
|
||||||
def tile_image(self, image_url=None, credits=None, credits_url=None,
|
def tile_image(
|
||||||
dec_deg=0.0, ra_deg=0.0, rotation_deg=0.0, scale_deg=1.0,
|
self,
|
||||||
thumbnail_url=None, x_offset_deg=0.0, y_offset_deg=0.0):
|
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.
|
"""Create a :ref:`TileImage <endpoint-TileImage>` request object.
|
||||||
|
|
||||||
Parameters are assigned to attributes of the return value; see
|
Parameters are assigned to attributes of the return value; see
|
||||||
|
@ -195,12 +214,12 @@ def _get_our_encoding():
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
enc = sys.getdefaultencoding()
|
enc = sys.getdefaultencoding()
|
||||||
if enc == 'ascii':
|
if enc == "ascii":
|
||||||
return 'utf-8'
|
return "utf-8"
|
||||||
return enc
|
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
|
import codecs
|
||||||
|
|
||||||
if obj is None:
|
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:
|
if in_enc is None:
|
||||||
in_enc = _get_our_encoding()
|
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'".
|
# If we don't special-case, b'abc' becomes "b'abc'".
|
||||||
#
|
#
|
||||||
# It would also be nice if we could validate that *obj* is
|
# 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.
|
# this API I don't expect the overhead to be significant.
|
||||||
text = codecs.decode(obj, in_enc)
|
text = codecs.decode(obj, in_enc)
|
||||||
else:
|
else:
|
||||||
text = six.text_type(obj)
|
text = str(obj)
|
||||||
|
|
||||||
if xml_esc:
|
if xml_esc:
|
||||||
text = xml_escape(text, {'"': '"'})
|
text = xml_escape(text, {'"': """})
|
||||||
|
|
||||||
return codecs.encode(text, out_enc)
|
return codecs.encode(text, out_enc)
|
||||||
|
|
||||||
|
@ -230,7 +249,7 @@ def _is_textable(obj, none_ok=False):
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return none_ok
|
return none_ok
|
||||||
|
|
||||||
if isinstance(obj, six.binary_type):
|
if isinstance(obj, bytes):
|
||||||
import codecs
|
import codecs
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -240,7 +259,7 @@ def _is_textable(obj, none_ok=False):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
six.text_type(obj)
|
str(obj)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
@ -255,21 +274,21 @@ def _is_absurl(obj, none_ok=False):
|
||||||
# allow ASCII in and out.
|
# allow ASCII in and out.
|
||||||
import codecs
|
import codecs
|
||||||
|
|
||||||
if isinstance(obj, six.binary_type):
|
if isinstance(obj, bytes):
|
||||||
# If we don't special-case, b'abc' becomes "b'abc'".
|
# If we don't special-case, b'abc' becomes "b'abc'".
|
||||||
try:
|
try:
|
||||||
text = codecs.decode(obj, 'ascii')
|
text = codecs.decode(obj, "ascii")
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
text = six.text_type(obj)
|
text = str(obj)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# We also need to be able to go the other way:
|
# We also need to be able to go the other way:
|
||||||
try:
|
try:
|
||||||
codecs.encode(text, 'ascii')
|
codecs.encode(text, "ascii")
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -292,7 +311,10 @@ def _is_scalar(obj, none_ok=False):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
import math
|
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):
|
class APIRequest(object):
|
||||||
|
@ -312,6 +334,7 @@ class APIRequest(object):
|
||||||
The client with which this request is associated.
|
The client with which this request is associated.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_client = None
|
_client = None
|
||||||
|
|
||||||
def __init__(self, client):
|
def __init__(self, client):
|
||||||
|
@ -348,7 +371,7 @@ class APIRequest(object):
|
||||||
--------
|
--------
|
||||||
Get the URL that will be accessed for a request::
|
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
|
>>> from wwt_api_client import Client
|
||||||
>>> req = Client().show_image('http://example.com/space.jpg', 'My Image')
|
>>> req = Client().show_image('http://example.com/space.jpg', 'My Image')
|
||||||
>>> parsed_url = urlparse(req.make_request().prepare().url)
|
>>> parsed_url = urlparse(req.make_request().prepare().url)
|
||||||
|
@ -422,22 +445,22 @@ class APIRequest(object):
|
||||||
def to_xml(self):
|
def to_xml(self):
|
||||||
"""Issue the request and return its results as parsed XML."""
|
"""Issue the request and return its results as parsed XML."""
|
||||||
from xml.etree import ElementTree as etree
|
from xml.etree import ElementTree as etree
|
||||||
|
|
||||||
text = self.send(raw_response=True).text
|
text = self.send(raw_response=True).text
|
||||||
return etree.fromstring(text)
|
return etree.fromstring(text)
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(APIRequest):
|
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."
|
"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."
|
"The version of the client logging in."
|
||||||
|
|
||||||
equinox_version_or_later = True
|
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):
|
def invalidity_reason(self):
|
||||||
if not _is_textable(self.user_guid):
|
if not _is_textable(self.user_guid):
|
||||||
|
@ -453,22 +476,23 @@ class LoginRequest(APIRequest):
|
||||||
|
|
||||||
def make_request(self):
|
def make_request(self):
|
||||||
params = [
|
params = [
|
||||||
('user', _maybe_as_bytes(self.user_guid)),
|
("user", _maybe_as_bytes(self.user_guid)),
|
||||||
('Version', _maybe_as_bytes(self.client_version)),
|
("Version", _maybe_as_bytes(self.client_version)),
|
||||||
]
|
]
|
||||||
|
|
||||||
if self.equinox_version_or_later:
|
if self.equinox_version_or_later:
|
||||||
params.append(('Equinox', 'true'))
|
params.append(("Equinox", "true"))
|
||||||
|
|
||||||
return requests.Request(
|
return requests.Request(
|
||||||
method = 'GET',
|
method="GET",
|
||||||
url = self._client._api_base + '/WWTWeb/login.aspx',
|
url=self._client._api_base + "/WWTWeb/login.aspx",
|
||||||
params = params,
|
params=params,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# TODO: connect this to wwt_data_formats!
|
# TODO: connect this to wwt_data_formats!
|
||||||
|
|
||||||
|
|
||||||
class ShowImageRequest(APIRequest):
|
class ShowImageRequest(APIRequest):
|
||||||
"""Request a WTML XML document suitable for showing an image in a client.
|
"""Request a WTML XML document suitable for showing an image in a client.
|
||||||
|
|
||||||
|
@ -497,6 +521,7 @@ class ShowImageRequest(APIRequest):
|
||||||
<endpoint-ShowImage>` endpoint.
|
<endpoint-ShowImage>` endpoint.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
credits = None
|
credits = None
|
||||||
"Free text describing where the image came from."
|
"Free text describing where the image came from."
|
||||||
|
|
||||||
|
@ -555,9 +580,12 @@ class ShowImageRequest(APIRequest):
|
||||||
if not _is_textable(self.name):
|
if not _is_textable(self.name):
|
||||||
return '"name" must be a string or an object that can be stringified'
|
return '"name" must be a string or an object that can be stringified'
|
||||||
|
|
||||||
if ',' in str(self.name):
|
if "," in str(self.name):
|
||||||
warnings.warn('ShowImage name {0} contains commas, which will be stripped '
|
warnings.warn(
|
||||||
'by the server'.format(self.name), UserWarning)
|
"ShowImage name {0} contains commas, which will be stripped "
|
||||||
|
"by the server".format(self.name),
|
||||||
|
UserWarning,
|
||||||
|
)
|
||||||
|
|
||||||
if not _is_scalar(self.ra_deg):
|
if not _is_scalar(self.ra_deg):
|
||||||
return '"ra_deg" must be a number'
|
return '"ra_deg" must be a number'
|
||||||
|
@ -571,7 +599,7 @@ class ShowImageRequest(APIRequest):
|
||||||
if not _is_scalar(self.scale_arcsec):
|
if not _is_scalar(self.scale_arcsec):
|
||||||
return '"scale_arcsec" must be a number'
|
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'
|
return '"scale_arcsec" must not be zero'
|
||||||
|
|
||||||
if not _is_absurl(self.thumbnail_url, none_ok=True):
|
if not _is_absurl(self.thumbnail_url, none_ok=True):
|
||||||
|
@ -587,38 +615,64 @@ class ShowImageRequest(APIRequest):
|
||||||
|
|
||||||
def make_request(self):
|
def make_request(self):
|
||||||
params = [
|
params = [
|
||||||
('dec', '%.18e' % float(self.dec_deg)),
|
("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)),
|
"imageurl",
|
||||||
('ra', '%.18e' % (float(self.ra_deg) % 360)), # The API clips, but we wrap
|
_maybe_as_bytes(
|
||||||
('rotation', '%.18e' % (float(self.rotation_deg) + 180)), # API is bizarre here
|
self.image_url, xml_esc=True, in_enc="ascii", out_enc="ascii"
|
||||||
('scale', '%.18e' % float(self.scale_arcsec)),
|
),
|
||||||
('wtml', 't'),
|
),
|
||||||
('x', '%.18e' % float(self.x_offset_pixels)),
|
("name", _maybe_as_bytes(self.name, xml_esc=True)),
|
||||||
('y', '%.18e' % float(self.y_offset_pixels)),
|
("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:
|
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:
|
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:
|
if self.reverse_parity:
|
||||||
params.append(('reverseparity', 't'))
|
params.append(("reverseparity", "t"))
|
||||||
|
|
||||||
if self.thumbnail_url is not None:
|
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(
|
return requests.Request(
|
||||||
method = 'GET',
|
method="GET",
|
||||||
url = self._client._api_base + '/WWTWeb/ShowImage.aspx',
|
url=self._client._api_base + "/WWTWeb/ShowImage.aspx",
|
||||||
params = params,
|
params=params,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# TODO: connect this to wwt_data_formats!
|
# TODO: connect this to wwt_data_formats!
|
||||||
|
|
||||||
|
|
||||||
class TileImageRequest(APIRequest):
|
class TileImageRequest(APIRequest):
|
||||||
"""Tile a large image on the server and obtain a WTML XML document suitable
|
"""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.
|
for displaying it in a client. FITS images are not supported.
|
||||||
|
@ -647,6 +701,7 @@ class TileImageRequest(APIRequest):
|
||||||
<endpoint-TileImage>` endpoint.
|
<endpoint-TileImage>` endpoint.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
credits = None
|
credits = None
|
||||||
"Free text describing where the image came from."
|
"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.
|
Positive numbers move the image up relative to the viewport center.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def invalidity_reason(self):
|
def invalidity_reason(self):
|
||||||
if not _is_textable(self.credits, none_ok=True):
|
if not _is_textable(self.credits, none_ok=True):
|
||||||
return '"credits" must be None or a string-like object'
|
return '"credits" must be None or a string-like object'
|
||||||
|
@ -720,7 +776,7 @@ class TileImageRequest(APIRequest):
|
||||||
|
|
||||||
if self.scale_deg is not None:
|
if self.scale_deg is not None:
|
||||||
scale = float(self.scale_deg)
|
scale = float(self.scale_deg)
|
||||||
if scale == 0.:
|
if scale == 0.0:
|
||||||
return '"scale_deg" must not be zero'
|
return '"scale_deg" must not be zero'
|
||||||
|
|
||||||
if not _is_absurl(self.thumbnail_url, none_ok=True):
|
if not _is_absurl(self.thumbnail_url, none_ok=True):
|
||||||
|
@ -736,38 +792,62 @@ class TileImageRequest(APIRequest):
|
||||||
|
|
||||||
def make_request(self):
|
def make_request(self):
|
||||||
params = [
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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(
|
return requests.Request(
|
||||||
method = 'GET',
|
method="GET",
|
||||||
url = self._client._api_base + '/WWTWeb/TileImage.aspx',
|
url=self._client._api_base + "/WWTWeb/TileImage.aspx",
|
||||||
params = params,
|
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
|
# 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."""
|
"""Interacting with the WWT Communities APIs."""
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from . import APIRequest, Client, enums
|
from . import APIRequest, Client, enums
|
||||||
|
|
||||||
__all__ = '''
|
__all__ = """
|
||||||
CommunitiesAPIRequest
|
CommunitiesAPIRequest
|
||||||
CommunitiesClient
|
CommunitiesClient
|
||||||
CreateCommunityRequest
|
CreateCommunityRequest
|
||||||
|
@ -22,15 +22,16 @@ GetMyProfileRequest
|
||||||
GetProfileEntitiesRequest
|
GetProfileEntitiesRequest
|
||||||
IsUserRegisteredRequest
|
IsUserRegisteredRequest
|
||||||
interactive_communities_login
|
interactive_communities_login
|
||||||
'''.split()
|
""".split()
|
||||||
|
|
||||||
LIVE_OAUTH_AUTH_SERVICE = "https://login.live.com/oauth20_authorize.srf"
|
LIVE_OAUTH_AUTH_SERVICE = "https://login.live.com/oauth20_authorize.srf"
|
||||||
LIVE_OAUTH_TOKEN_SERVICE = "https://login.live.com/oauth20_token.srf"
|
LIVE_OAUTH_TOKEN_SERVICE = "https://login.live.com/oauth20_token.srf"
|
||||||
LIVE_OAUTH_DESKTOP_ENDPOINT = "https://login.live.com/oauth20_desktop.srf"
|
LIVE_OAUTH_DESKTOP_ENDPOINT = "https://login.live.com/oauth20_desktop.srf"
|
||||||
LIVE_AUTH_SCOPES = ['wl.emails', 'wl.signin']
|
LIVE_AUTH_SCOPES = ["wl.emails", "wl.signin"]
|
||||||
WWT_CLIENT_ID = '000000004015657B'
|
WWT_CLIENT_ID = "000000004015657B"
|
||||||
OAUTH_STATE_BASENAME = 'communities-oauth.json'
|
OAUTH_STATE_BASENAME = "communities-oauth.json"
|
||||||
CLIENT_SECRET_BASENAME = 'communities-client-secret.txt'
|
CLIENT_SECRET_BASENAME = "communities-client-secret.txt"
|
||||||
|
|
||||||
|
|
||||||
class CommunitiesClient(object):
|
class CommunitiesClient(object):
|
||||||
"""A client for WWT Communities API requests.
|
"""A client for WWT Communities API requests.
|
||||||
|
@ -46,18 +47,26 @@ class CommunitiesClient(object):
|
||||||
subsequent use.
|
subsequent use.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_parent = None
|
_parent = None
|
||||||
_state_dir = None
|
_state_dir = None
|
||||||
_state = None
|
_state = None
|
||||||
_access_token = None
|
_access_token = None
|
||||||
_refresh_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
|
self._parent = parent_client
|
||||||
|
|
||||||
if state_dir is None:
|
if state_dir is None:
|
||||||
import appdirs
|
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
|
self._state_dir = state_dir
|
||||||
|
|
||||||
|
@ -66,20 +75,24 @@ class CommunitiesClient(object):
|
||||||
|
|
||||||
if oauth_client_secret is None:
|
if oauth_client_secret is None:
|
||||||
try:
|
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()
|
oauth_client_secret = f.readline().strip()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if oauth_client_secret is None:
|
if oauth_client_secret is None:
|
||||||
raise Exception('cannot create CommunitiesClient: the \"oauth client secret\" '
|
raise Exception(
|
||||||
'is not available to the program')
|
'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
|
# Try to get state from a previous OAuth flow and decide what to do
|
||||||
# based on where we're at.
|
# based on where we're at.
|
||||||
|
|
||||||
try:
|
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)
|
self._state = json.load(f)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
@ -89,9 +102,9 @@ class CommunitiesClient(object):
|
||||||
# redirect_uri's.
|
# redirect_uri's.
|
||||||
|
|
||||||
token_service_params = {
|
token_service_params = {
|
||||||
'client_id': WWT_CLIENT_ID,
|
"client_id": WWT_CLIENT_ID,
|
||||||
'client_secret': oauth_client_secret,
|
"client_secret": oauth_client_secret,
|
||||||
'redirect_uri': LIVE_OAUTH_DESKTOP_ENDPOINT,
|
"redirect_uri": LIVE_OAUTH_DESKTOP_ENDPOINT,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Once set, the structure of oauth_data is : {
|
# 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
|
# We have previous state -- hopefully, we only need a refresh, which
|
||||||
# can proceed non-interactively.
|
# can proceed non-interactively.
|
||||||
|
|
||||||
token_service_params['grant_type'] = 'refresh_token'
|
token_service_params["grant_type"] = "refresh_token"
|
||||||
token_service_params['refresh_token'] = self._state['refresh_token']
|
token_service_params["refresh_token"] = self._state["refresh_token"]
|
||||||
|
|
||||||
oauth_data = requests.post(
|
oauth_data = requests.post(
|
||||||
LIVE_OAUTH_TOKEN_SERVICE,
|
LIVE_OAUTH_TOKEN_SERVICE,
|
||||||
data = token_service_params,
|
data=token_service_params,
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
if 'error' in oauth_data:
|
if "error" in oauth_data:
|
||||||
if oauth_data['error'] == 'invalid_grant':
|
if oauth_data["error"] == "invalid_grant":
|
||||||
# This indicates that our grant has expired. We need to
|
# This indicates that our grant has expired. We need to
|
||||||
# rerun the auth flow.
|
# rerun the auth flow.
|
||||||
self._state = None
|
self._state = None
|
||||||
|
@ -132,67 +145,82 @@ class CommunitiesClient(object):
|
||||||
# programs pausing for user input on the terminal.
|
# programs pausing for user input on the terminal.
|
||||||
|
|
||||||
if not interactive_login_if_needed:
|
if not interactive_login_if_needed:
|
||||||
raise Exception('cannot create CommunitiesClient: an interactive login is '
|
raise Exception(
|
||||||
'required but unavailable right now')
|
"cannot create CommunitiesClient: an interactive login is "
|
||||||
|
"required but unavailable right now"
|
||||||
|
)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'client_id': WWT_CLIENT_ID,
|
"client_id": WWT_CLIENT_ID,
|
||||||
'scope': ' '.join(LIVE_AUTH_SCOPES),
|
"scope": " ".join(LIVE_AUTH_SCOPES),
|
||||||
'redirect_uri': LIVE_OAUTH_DESKTOP_ENDPOINT,
|
"redirect_uri": LIVE_OAUTH_DESKTOP_ENDPOINT,
|
||||||
'response_type': 'code'
|
"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()
|
||||||
print('To use the WWT Communities APIs, interactive authentication to Microsoft')
|
print(
|
||||||
print('Live is required. Open this URL in a browser and log in:')
|
"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()
|
||||||
print(preq.url)
|
print(preq.url)
|
||||||
print()
|
print()
|
||||||
print('When done, copy the URL *that you are redirected to* and paste it here:')
|
print(
|
||||||
print('>> ', end='')
|
"When done, copy the URL *that you are redirected to* and paste it here:"
|
||||||
|
)
|
||||||
|
print(">> ", end="")
|
||||||
redir_url = input()
|
redir_url = input()
|
||||||
# should look like:
|
# should look like:
|
||||||
# 'https://login.live.com/oauth20_desktop.srf?code=MHEXHEXHE-XHEX-HEXH-EXHE-XHEXHEXHEXHE&lc=NNNN'
|
# 'https://login.live.com/oauth20_desktop.srf?code=MHEXHEXHE-XHEX-HEXH-EXHE-XHEXHEXHEXHE&lc=NNNN'
|
||||||
|
|
||||||
parsed = urlparse(redir_url)
|
parsed = urlparse(redir_url)
|
||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
code = params.get('code')
|
code = params.get("code")
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
raise Exception('didn\'t get "code" parameter from response URL')
|
raise Exception('didn\'t get "code" parameter from response URL')
|
||||||
|
|
||||||
token_service_params['grant_type'] = 'authorization_code'
|
token_service_params["grant_type"] = "authorization_code"
|
||||||
token_service_params['code'] = code
|
token_service_params["code"] = code
|
||||||
|
|
||||||
oauth_data = requests.post(
|
oauth_data = requests.post(
|
||||||
LIVE_OAUTH_TOKEN_SERVICE,
|
LIVE_OAUTH_TOKEN_SERVICE,
|
||||||
data = token_service_params,
|
data=token_service_params,
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
if 'error' in oauth_data:
|
if "error" in oauth_data:
|
||||||
raise Exception(repr(oauth_data))
|
raise Exception(repr(oauth_data))
|
||||||
|
|
||||||
# Looks like it worked! Save the results for next time.
|
# Looks like it worked! Save the results for next time.
|
||||||
os.makedirs(self._state_dir, exist_ok=True)
|
os.makedirs(self._state_dir, exist_ok=True)
|
||||||
|
|
||||||
# Sigh, Python not making it easy to be secure ...
|
# 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)
|
fd = os.open(
|
||||||
f = open(fd, 'wt')
|
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:
|
with f:
|
||||||
json.dump(oauth_data, 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)
|
fd = os.open(
|
||||||
f = open(fd, 'wt')
|
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:
|
with f:
|
||||||
print(oauth_client_secret, file=f)
|
print(oauth_client_secret, file=f)
|
||||||
|
|
||||||
# And for this time:
|
# And for this time:
|
||||||
|
|
||||||
self._access_token = oauth_data['access_token']
|
self._access_token = oauth_data["access_token"]
|
||||||
self._refresh_token = oauth_data['refresh_token']
|
self._refresh_token = oauth_data["refresh_token"]
|
||||||
|
|
||||||
|
|
||||||
def create_community(self, payload=None):
|
def create_community(self, payload=None):
|
||||||
"""Create a new community owned by the current user.
|
"""Create a new community owned by the current user.
|
||||||
|
@ -211,7 +239,6 @@ class CommunitiesClient(object):
|
||||||
req.payload = payload
|
req.payload = payload
|
||||||
return req
|
return req
|
||||||
|
|
||||||
|
|
||||||
def delete_community(self, id=None):
|
def delete_community(self, id=None):
|
||||||
"""Delete a community.
|
"""Delete a community.
|
||||||
|
|
||||||
|
@ -229,7 +256,6 @@ class CommunitiesClient(object):
|
||||||
req.id = id
|
req.id = id
|
||||||
return req
|
return req
|
||||||
|
|
||||||
|
|
||||||
def get_community_info(self, id=None):
|
def get_community_info(self, id=None):
|
||||||
"""Get information about the specified community.
|
"""Get information about the specified community.
|
||||||
|
|
||||||
|
@ -247,7 +273,6 @@ class CommunitiesClient(object):
|
||||||
req.id = id
|
req.id = id
|
||||||
return req
|
return req
|
||||||
|
|
||||||
|
|
||||||
def get_latest_community(self):
|
def get_latest_community(self):
|
||||||
"""Get information about the most recently created WWT Communities.
|
"""Get information about the most recently created WWT Communities.
|
||||||
|
|
||||||
|
@ -270,7 +295,6 @@ class CommunitiesClient(object):
|
||||||
"""
|
"""
|
||||||
return GetLatestCommunityRequest(self)
|
return GetLatestCommunityRequest(self)
|
||||||
|
|
||||||
|
|
||||||
def get_my_profile(self):
|
def get_my_profile(self):
|
||||||
"""Get the logged-in user's profile information.
|
"""Get the logged-in user's profile information.
|
||||||
|
|
||||||
|
@ -295,12 +319,11 @@ class CommunitiesClient(object):
|
||||||
"""
|
"""
|
||||||
return GetMyProfileRequest(self)
|
return GetMyProfileRequest(self)
|
||||||
|
|
||||||
|
|
||||||
def get_profile_entities(
|
def get_profile_entities(
|
||||||
self,
|
self,
|
||||||
entity_type = enums.EntityType.CONTENT,
|
entity_type=enums.EntityType.CONTENT,
|
||||||
current_page = 1,
|
current_page=1,
|
||||||
page_size = 99999,
|
page_size=99999,
|
||||||
):
|
):
|
||||||
"""Get "entities" associated with the logged-in user's profile.
|
"""Get "entities" associated with the logged-in user's profile.
|
||||||
|
|
||||||
|
@ -337,7 +360,6 @@ class CommunitiesClient(object):
|
||||||
req.page_size = page_size
|
req.page_size = page_size
|
||||||
return req
|
return req
|
||||||
|
|
||||||
|
|
||||||
def is_user_registered(self):
|
def is_user_registered(self):
|
||||||
"""Query whether the logged-in Microsoft Live user is registered with
|
"""Query whether the logged-in Microsoft Live user is registered with
|
||||||
the WWT Communities system.
|
the WWT Communities system.
|
||||||
|
@ -369,6 +391,7 @@ class CommunitiesAPIRequest(APIRequest):
|
||||||
These require that the user be logged in to a Microsoft Live account.
|
These require that the user be logged in to a Microsoft Live account.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_comm_client = None
|
_comm_client = None
|
||||||
|
|
||||||
def __init__(self, communities_client):
|
def __init__(self, communities_client):
|
||||||
|
@ -382,6 +405,7 @@ class CreateCommunityRequest(CommunitiesAPIRequest):
|
||||||
The response gives the ID of the new community.
|
The response gives the ID of the new community.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
payload = None
|
payload = None
|
||||||
"""The request payload is JSON resembling::
|
"""The request payload is JSON resembling::
|
||||||
|
|
||||||
|
@ -403,6 +427,7 @@ class CreateCommunityRequest(CommunitiesAPIRequest):
|
||||||
data structure at the moment.)
|
data structure at the moment.)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def invalidity_reason(self):
|
def invalidity_reason(self):
|
||||||
if self.payload is None:
|
if self.payload is None:
|
||||||
return '"payload" must be a JSON dictionary'
|
return '"payload" must be a JSON dictionary'
|
||||||
|
@ -411,18 +436,18 @@ class CreateCommunityRequest(CommunitiesAPIRequest):
|
||||||
|
|
||||||
def make_request(self):
|
def make_request(self):
|
||||||
return requests.Request(
|
return requests.Request(
|
||||||
method = 'POST',
|
method="POST",
|
||||||
url = self._client._api_base + '/Community/Create/New',
|
url=self._client._api_base + "/Community/Create/New",
|
||||||
json = self.payload,
|
json=self.payload,
|
||||||
cookies = {
|
cookies={
|
||||||
'access_token': self._comm_client._access_token,
|
"access_token": self._comm_client._access_token,
|
||||||
'refresh_token': self._comm_client._refresh_token,
|
"refresh_token": self._comm_client._refresh_token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _process_response(self, resp):
|
def _process_response(self, resp):
|
||||||
s = json.loads(resp.text)
|
s = json.loads(resp.text)
|
||||||
return s['ID']
|
return s["ID"]
|
||||||
|
|
||||||
|
|
||||||
class DeleteCommunityRequest(CommunitiesAPIRequest):
|
class DeleteCommunityRequest(CommunitiesAPIRequest):
|
||||||
|
@ -431,6 +456,7 @@ class DeleteCommunityRequest(CommunitiesAPIRequest):
|
||||||
Returns True if the community was successfully deleted, False otherwise.
|
Returns True if the community was successfully deleted, False otherwise.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = None
|
id = None
|
||||||
"The ID number of the community to delete"
|
"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
|
# The API includes a {parentId} after the community ID, but it is
|
||||||
# unused.
|
# unused.
|
||||||
return requests.Request(
|
return requests.Request(
|
||||||
method = 'POST',
|
method="POST",
|
||||||
url = f'{self._client._api_base}/Community/Delete/{self.id}/0',
|
url=f"{self._client._api_base}/Community/Delete/{self.id}/0",
|
||||||
cookies = {
|
cookies={
|
||||||
'access_token': self._comm_client._access_token,
|
"access_token": self._comm_client._access_token,
|
||||||
'refresh_token': self._comm_client._refresh_token,
|
"refresh_token": self._comm_client._refresh_token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _process_response(self, resp):
|
def _process_response(self, resp):
|
||||||
t = resp.text
|
t = resp.text
|
||||||
|
|
||||||
if t == 'True':
|
if t == "True":
|
||||||
return True
|
return True
|
||||||
elif t == 'False':
|
elif t == "False":
|
||||||
return 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
|
# TODO: we're not implementing the "isEdit" mode where you can update
|
||||||
|
@ -536,6 +562,7 @@ class GetCommunityInfoRequest(CommunitiesAPIRequest):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = None
|
id = None
|
||||||
"The ID number of the community to probe"
|
"The ID number of the community to probe"
|
||||||
|
|
||||||
|
@ -547,13 +574,13 @@ class GetCommunityInfoRequest(CommunitiesAPIRequest):
|
||||||
|
|
||||||
def make_request(self):
|
def make_request(self):
|
||||||
return requests.Request(
|
return requests.Request(
|
||||||
method = 'GET',
|
method="GET",
|
||||||
url = f'{self._client._api_base}/Community/Detail/{self.id}',
|
url=f"{self._client._api_base}/Community/Detail/{self.id}",
|
||||||
cookies = {
|
cookies={
|
||||||
'access_token': self._comm_client._access_token,
|
"access_token": self._comm_client._access_token,
|
||||||
'refresh_token': self._comm_client._refresh_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):
|
def _process_response(self, resp):
|
||||||
|
@ -566,19 +593,21 @@ class GetLatestCommunityRequest(CommunitiesAPIRequest):
|
||||||
sub-Folders corresponding to the communities.
|
sub-Folders corresponding to the communities.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def invalidity_reason(self):
|
def invalidity_reason(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def make_request(self):
|
def make_request(self):
|
||||||
return requests.Request(
|
return requests.Request(
|
||||||
method = 'GET',
|
method="GET",
|
||||||
url = self._client._api_base + '/Resource/Service/Browse/LatestCommunity',
|
url=self._client._api_base + "/Resource/Service/Browse/LatestCommunity",
|
||||||
headers = {'LiveUserToken': self._comm_client._access_token},
|
headers={"LiveUserToken": self._comm_client._access_token},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _process_response(self, resp):
|
def _process_response(self, resp):
|
||||||
from wwt_data_formats.folder import Folder
|
from wwt_data_formats.folder import Folder
|
||||||
from xml.etree import ElementTree as etree
|
from xml.etree import ElementTree as etree
|
||||||
|
|
||||||
xml = etree.fromstring(resp.text)
|
xml = etree.fromstring(resp.text)
|
||||||
return Folder.from_xml(xml)
|
return Folder.from_xml(xml)
|
||||||
|
|
||||||
|
@ -602,19 +631,20 @@ class GetMyProfileRequest(CommunitiesAPIRequest):
|
||||||
'IsSubscribed': False
|
'IsSubscribed': False
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def invalidity_reason(self):
|
def invalidity_reason(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def make_request(self):
|
def make_request(self):
|
||||||
return requests.Request(
|
return requests.Request(
|
||||||
method = 'GET',
|
method="GET",
|
||||||
url = self._client._api_base + '/Profile/MyProfile/Get',
|
url=self._client._api_base + "/Profile/MyProfile/Get",
|
||||||
headers = {
|
headers={
|
||||||
'Accept': 'application/json, text/plain, */*',
|
"Accept": "application/json, text/plain, */*",
|
||||||
},
|
},
|
||||||
cookies = {
|
cookies={
|
||||||
'access_token': self._comm_client._access_token,
|
"access_token": self._comm_client._access_token,
|
||||||
'refresh_token': self._comm_client._refresh_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.
|
Entities include communities, folders, and content files. The response is JSON.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
entity_type = enums.EntityType.CONTENT
|
entity_type = enums.EntityType.CONTENT
|
||||||
"What kind of entity to query. Only COMMUNITY and CONTENT are allowed."
|
"What kind of entity to query. Only COMMUNITY and CONTENT are allowed."
|
||||||
|
|
||||||
|
@ -648,11 +679,11 @@ class GetProfileEntitiesRequest(CommunitiesAPIRequest):
|
||||||
|
|
||||||
def make_request(self):
|
def make_request(self):
|
||||||
return requests.Request(
|
return requests.Request(
|
||||||
method = 'GET',
|
method="GET",
|
||||||
url = f'{self._client._api_base}/Profile/Entities/{self.entity_type.value}/{self.current_page}/{self.page_size}',
|
url=f"{self._client._api_base}/Profile/Entities/{self.entity_type.value}/{self.current_page}/{self.page_size}",
|
||||||
cookies = {
|
cookies={
|
||||||
'access_token': self._comm_client._access_token,
|
"access_token": self._comm_client._access_token,
|
||||||
'refresh_token': self._comm_client._refresh_token,
|
"refresh_token": self._comm_client._refresh_token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -665,41 +696,43 @@ class IsUserRegisteredRequest(CommunitiesAPIRequest):
|
||||||
Communities system.
|
Communities system.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def invalidity_reason(self):
|
def invalidity_reason(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def make_request(self):
|
def make_request(self):
|
||||||
return requests.Request(
|
return requests.Request(
|
||||||
method = 'GET',
|
method="GET",
|
||||||
url = self._client._api_base + '/Resource/Service/User',
|
url=self._client._api_base + "/Resource/Service/User",
|
||||||
headers = {'LiveUserToken': self._comm_client._access_token},
|
headers={"LiveUserToken": self._comm_client._access_token},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _process_response(self, resp):
|
def _process_response(self, resp):
|
||||||
t = resp.text
|
t = resp.text
|
||||||
|
|
||||||
if t == 'True':
|
if t == "True":
|
||||||
return True
|
return True
|
||||||
elif t == 'False':
|
elif t == "False":
|
||||||
return 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.
|
# Command-line utility for initializing the OAuth state.
|
||||||
|
|
||||||
|
|
||||||
def interactive_communities_login(args):
|
def interactive_communities_login(args):
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--secret-file',
|
"--secret-file",
|
||||||
metavar = 'PATH',
|
metavar="PATH",
|
||||||
help = 'Path to a file from which to read the WWT client secret',
|
help="Path to a file from which to read the WWT client secret",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--secret-env',
|
"--secret-env",
|
||||||
metavar = 'ENV-VAR-NAME',
|
metavar="ENV-VAR-NAME",
|
||||||
help = 'Name of an environment variable containing the WWT client secret',
|
help="Name of an environment variable containing the WWT client secret",
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = parser.parse_args(args)
|
settings = parser.parse_args(args)
|
||||||
|
@ -712,24 +745,27 @@ def interactive_communities_login(args):
|
||||||
elif settings.secret_env is not None:
|
elif settings.secret_env is not None:
|
||||||
client_secret = os.environ.get(settings.secret_env)
|
client_secret = os.environ.get(settings.secret_env)
|
||||||
else:
|
else:
|
||||||
print('error: the WWT \"client secret\" must be provided; '
|
print(
|
||||||
'use --secret-file or --secret-env', file=sys.stderr)
|
'error: the WWT "client secret" must be provided; '
|
||||||
|
"use --secret-file or --secret-env",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if not client_secret:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
# Ready to go ...
|
# Ready to go ...
|
||||||
|
|
||||||
CommunitiesClient(
|
CommunitiesClient(
|
||||||
Client(),
|
Client(),
|
||||||
oauth_client_secret = client_secret,
|
oauth_client_secret=client_secret,
|
||||||
interactive_login_if_needed = True,
|
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:])
|
interactive_communities_login(sys.argv[1:])
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# -*- mode: python; coding: utf-8 -*-
|
# -*- mode: python; coding: utf-8 -*-
|
||||||
# Copyright 2019 the .Net Foundation
|
# 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.
|
"""This module contains tests of the wwt_api_client package.
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# -*- mode: python; coding: utf-8 -*-
|
# -*- mode: python; coding: utf-8 -*-
|
||||||
# Copyright 2020 the .NET Foundation
|
# Copyright 2020-2023 the .NET Foundation
|
||||||
# Distributed under the terms of the revised (3-clause) BSD license.
|
# Distributed under the MIT license
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from mock import Mock
|
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:
|
if url == communities.LIVE_OAUTH_TOKEN_SERVICE:
|
||||||
rv.json.return_value = {
|
rv.json.return_value = {
|
||||||
'access_token': 'fake_access_token',
|
"access_token": "fake_access_token",
|
||||||
'refresh_token': 'fake_refresh_token',
|
"refresh_token": "fake_refresh_token",
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
raise Exception(f'unexpected URL to fake requests.post(): {url}')
|
raise Exception(f"unexpected URL to fake requests.post(): {url}")
|
||||||
|
|
||||||
return rv
|
return rv
|
||||||
|
|
||||||
GET_COMMUNITY_INFO_JSON_TEXT = '''
|
|
||||||
|
GET_COMMUNITY_INFO_JSON_TEXT = """
|
||||||
{
|
{
|
||||||
"community": {
|
"community": {
|
||||||
"MemberCount": 0,
|
"MemberCount": 0,
|
||||||
|
@ -96,25 +97,23 @@ GET_COMMUNITY_INFO_JSON_TEXT = '''
|
||||||
"IsFaulted": false
|
"IsFaulted": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
'''
|
"""
|
||||||
|
|
||||||
GET_LATEST_COMMUNITY_XML_TEXT = '''\
|
GET_LATEST_COMMUNITY_XML_TEXT = """\
|
||||||
<?xml version='1.0' encoding='UTF-8'?>
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
<Folder Browseable="True" Group="Explorer"
|
<Folder Browseable="True" Group="Explorer"
|
||||||
MSRCommunityId="0" MSRComponentId="0" Permission="0"
|
|
||||||
Searchable="True"
|
Searchable="True"
|
||||||
Thumbnail="http://www.worldwidetelescope.org/Content/Images/defaultfolderwwtthumbnail.png"
|
Thumbnail="http://www.worldwidetelescope.org/Content/Images/defaultfolderwwtthumbnail.png"
|
||||||
Type="Earth">
|
Type="Earth">
|
||||||
<Folder Browseable="True" Group="Explorer" Name="AAS Nova"
|
<Folder Browseable="True" Group="Explorer" Name="AAS Nova"
|
||||||
MSRCommunityId="0" MSRComponentId="0" Permission="0"
|
|
||||||
Searchable="True"
|
Searchable="True"
|
||||||
Thumbnail="http://www.worldwidetelescope.org/File/Thumbnail/80a8d8ef-8a76-414a-a398-349337baac8c"
|
Thumbnail="http://www.worldwidetelescope.org/File/Thumbnail/80a8d8ef-8a76-414a-a398-349337baac8c"
|
||||||
Url="http://www.worldwidetelescope.org/Resource/Service/Folder/607649"
|
Url="http://www.worldwidetelescope.org/Resource/Service/Folder/607649"
|
||||||
Type="Earth" />
|
Type="Earth" />
|
||||||
</Folder>
|
</Folder>
|
||||||
'''
|
"""
|
||||||
|
|
||||||
GET_MY_PROFILE_JSON_TEXT = '''
|
GET_MY_PROFILE_JSON_TEXT = """
|
||||||
{
|
{
|
||||||
"ProfileId": 123456,
|
"ProfileId": 123456,
|
||||||
"ProfileName": "Firstname Lastname",
|
"ProfileName": "Firstname Lastname",
|
||||||
|
@ -128,9 +127,9 @@ GET_MY_PROFILE_JSON_TEXT = '''
|
||||||
"IsCurrentUser": true,
|
"IsCurrentUser": true,
|
||||||
"IsSubscribed": false
|
"IsSubscribed": false
|
||||||
}
|
}
|
||||||
'''
|
"""
|
||||||
|
|
||||||
GET_PROFILE_ENTITIES_JSON_TEXT = '''
|
GET_PROFILE_ENTITIES_JSON_TEXT = """
|
||||||
{
|
{
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
|
@ -176,37 +175,40 @@ GET_PROFILE_ENTITIES_JSON_TEXT = '''
|
||||||
"TotalCount": 1
|
"TotalCount": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
'''
|
"""
|
||||||
|
|
||||||
|
|
||||||
def fake_request_session_send(request, **kwargs):
|
def fake_request_session_send(request, **kwargs):
|
||||||
rv = Mock()
|
rv = Mock()
|
||||||
|
|
||||||
if request.url == DEFAULT_API_BASE + '/Community/Create/New':
|
if request.url == DEFAULT_API_BASE + "/Community/Create/New":
|
||||||
rv.text = '{"ID": 800000}'
|
rv.text = '{"ID": 800000}'
|
||||||
elif request.url == DEFAULT_API_BASE + '/Community/Delete/800000/0':
|
elif request.url == DEFAULT_API_BASE + "/Community/Delete/800000/0":
|
||||||
rv.text = 'True'
|
rv.text = "True"
|
||||||
elif request.url == DEFAULT_API_BASE + '/Community/Detail/800000':
|
elif request.url == DEFAULT_API_BASE + "/Community/Detail/800000":
|
||||||
rv.text = GET_COMMUNITY_INFO_JSON_TEXT
|
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
|
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
|
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
|
rv.text = GET_LATEST_COMMUNITY_XML_TEXT
|
||||||
elif request.url == DEFAULT_API_BASE + '/Resource/Service/User':
|
elif request.url == DEFAULT_API_BASE + "/Resource/Service/User":
|
||||||
rv.text = 'True'
|
rv.text = "True"
|
||||||
else:
|
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
|
return rv
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def fake_requests(mocker):
|
def fake_requests(mocker):
|
||||||
m = mocker.patch('requests.post')
|
m = mocker.patch("requests.post")
|
||||||
m.side_effect = fake_request_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
|
m.side_effect = fake_request_session_send
|
||||||
|
|
||||||
|
|
||||||
|
@ -214,19 +216,21 @@ def fake_requests(mocker):
|
||||||
def communities_client_cached(client, fake_requests):
|
def communities_client_cached(client, fake_requests):
|
||||||
temp_state_dir = tempfile.mkdtemp()
|
temp_state_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
with open(os.path.join(temp_state_dir, communities.CLIENT_SECRET_BASENAME), 'w') as f:
|
with open(
|
||||||
print('fake_client_secret', file=f)
|
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 = {
|
oauth_data = {
|
||||||
'access_token': 'fake_access_token',
|
"access_token": "fake_access_token",
|
||||||
'refresh_token': 'fake_refresh_token',
|
"refresh_token": "fake_refresh_token",
|
||||||
}
|
}
|
||||||
json.dump(oauth_data, f)
|
json.dump(oauth_data, f)
|
||||||
|
|
||||||
yield CommunitiesClient(
|
yield CommunitiesClient(
|
||||||
client,
|
client,
|
||||||
state_dir = temp_state_dir,
|
state_dir=temp_state_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
shutil.rmtree(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):
|
def communities_client_interactive(client, fake_requests, mocker):
|
||||||
temp_state_dir = tempfile.mkdtemp()
|
temp_state_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
m = mocker.patch('builtins.input')
|
m = mocker.patch("builtins.input")
|
||||||
m.return_value = 'http://fakelogin.example.com?code=fake_code'
|
m.return_value = "http://fakelogin.example.com?code=fake_code"
|
||||||
|
|
||||||
yield CommunitiesClient(
|
yield CommunitiesClient(
|
||||||
client,
|
client,
|
||||||
oauth_client_secret = 'fake_client_secret',
|
oauth_client_secret="fake_client_secret",
|
||||||
interactive_login_if_needed=True,
|
interactive_login_if_needed=True,
|
||||||
state_dir = temp_state_dir,
|
state_dir=temp_state_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
shutil.rmtree(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):
|
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
|
m.return_value = communities_client_interactive
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
communities.interactive_communities_login([])
|
communities.interactive_communities_login([])
|
||||||
|
|
||||||
os.environ['FAKE_CLIENT_SECRET'] = 'fakey'
|
os.environ["FAKE_CLIENT_SECRET"] = "fakey"
|
||||||
communities.interactive_communities_login(['--secret-env=FAKE_CLIENT_SECRET'])
|
communities.interactive_communities_login(["--secret-env=FAKE_CLIENT_SECRET"])
|
||||||
|
|
||||||
f = tempfile.NamedTemporaryFile(mode='wt', delete=False)
|
f = tempfile.NamedTemporaryFile(mode="wt", delete=False)
|
||||||
print('fake_client_secret', file=f)
|
print("fake_client_secret", file=f)
|
||||||
f.close()
|
f.close()
|
||||||
communities.interactive_communities_login([f'--secret-file={f.name}'])
|
communities.interactive_communities_login([f"--secret-file={f.name}"])
|
||||||
os.unlink(f.name)
|
os.unlink(f.name)
|
||||||
|
|
||||||
|
|
||||||
def test_create_community(communities_client_cached):
|
def test_create_community(communities_client_cached):
|
||||||
payload = {
|
payload = {
|
||||||
'communityJson': {
|
"communityJson": {
|
||||||
'CategoryID': 20,
|
"CategoryID": 20,
|
||||||
'ParentID': '610131',
|
"ParentID": "610131",
|
||||||
'AccessTypeID': 2,
|
"AccessTypeID": 2,
|
||||||
'IsOffensive': False,
|
"IsOffensive": False,
|
||||||
'IsLink': False,
|
"IsLink": False,
|
||||||
'CommunityType': 'Community',
|
"CommunityType": "Community",
|
||||||
'Name': 'API Test Community',
|
"Name": "API Test Community",
|
||||||
'Description': 'Community description',
|
"Description": "Community description",
|
||||||
'Tags': 'tag1,tag2'
|
"Tags": "tag1,tag2",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
new_id = communities_client_cached.create_community(payload=payload).send()
|
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):
|
def test_get_profile_entities(communities_client_cached):
|
||||||
expected_json = json.loads(GET_PROFILE_ENTITIES_JSON_TEXT)
|
expected_json = json.loads(GET_PROFILE_ENTITIES_JSON_TEXT)
|
||||||
observed_json = communities_client_cached.get_profile_entities(
|
observed_json = communities_client_cached.get_profile_entities(
|
||||||
entity_type = enums.EntityType.CONTENT,
|
entity_type=enums.EntityType.CONTENT,
|
||||||
current_page = 1,
|
current_page=1,
|
||||||
page_size = 99999,
|
page_size=99999,
|
||||||
).send()
|
).send()
|
||||||
assert observed_json == expected_json
|
assert observed_json == expected_json
|
||||||
|
|
||||||
|
|
||||||
def test_is_user_registered(communities_client_cached):
|
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()
|
assert communities_client_cached.is_user_registered().send()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# -*- mode: python; coding: utf-8 -*-
|
# -*- mode: python; coding: utf-8 -*-
|
||||||
# Copyright 2019 the .Net Foundation
|
# Copyright 2019-2023 the .Net Foundation
|
||||||
# Distributed under the terms of the revised (3-clause) BSD license.
|
# Distributed under the MIT license
|
||||||
|
|
||||||
"""Note! This test suite will hit the network!
|
"""Note! This test suite will hit the network!
|
||||||
|
|
||||||
|
@ -11,137 +11,157 @@ from xml.etree import ElementTree
|
||||||
from .. import Client
|
from .. import Client
|
||||||
|
|
||||||
|
|
||||||
INF = float('inf')
|
INF = float("inf")
|
||||||
NAN = float('nan')
|
NAN = float("nan")
|
||||||
|
|
||||||
|
|
||||||
def _assert_xml_trees_equal(path, e1, e2, care_text_tags):
|
def _assert_xml_trees_equal(path, e1, e2, care_text_tags):
|
||||||
"Derived from https://stackoverflow.com/a/24349916/3760486"
|
"Derived from https://stackoverflow.com/a/24349916/3760486"
|
||||||
|
|
||||||
assert e1.tag == e2.tag, \
|
assert e1.tag == e2.tag, "at XML path {0}, tags {1} and {2} differed".format(
|
||||||
'at XML path {0}, tags {1} and {2} differed'.format(path, e1.tag, e2.tag)
|
path, e1.tag, e2.tag
|
||||||
|
)
|
||||||
|
|
||||||
# We only sometimes care about this; often it's just whitespace
|
# We only sometimes care about this; often it's just whitespace
|
||||||
if e1.tag in care_text_tags:
|
if e1.tag in care_text_tags:
|
||||||
assert e1.text == e2.text, \
|
assert (
|
||||||
'at XML path {0}, texts {1!r} and {2!r} differed'.format(path, e1.text, e2.text)
|
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?
|
# 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)
|
# 'at XML path {0}, tails {1!r} and {2!r} differed'.format(path, e1.tail, e2.tail)
|
||||||
|
|
||||||
assert e1.attrib == e2.attrib, \
|
assert (
|
||||||
'at XML path {0}, attributes {1!r} and {2!r} differed'.format(path, e1.attrib, e2.attrib)
|
e1.attrib == e2.attrib
|
||||||
assert len(e1) == len(e2), \
|
), "at XML path {0}, attributes {1!r} and {2!r} differed".format(
|
||||||
'at XML path {0}, number of children {1} and {2} differed'.format(path, len(e1), len(e2))
|
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)
|
_assert_xml_trees_equal(subpath, c1, c2, care_text_tags)
|
||||||
|
|
||||||
|
|
||||||
def assert_xml_trees_equal(e1, e2, 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
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
return Client()
|
return Client()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def login(client):
|
def login(client):
|
||||||
"Return a valid login request object."
|
"Return a valid login request object."
|
||||||
return client.login()
|
return client.login()
|
||||||
|
|
||||||
|
|
||||||
def test_login_basic(login):
|
def test_login_basic(login):
|
||||||
assert login.invalidity_reason() is None
|
assert login.invalidity_reason() is None
|
||||||
login.send()
|
login.send()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def showimage(client):
|
def showimage(client):
|
||||||
"Return a valid ShowImage request object."
|
"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 = [
|
SHOWIMAGE_BAD_SETTINGS = [
|
||||||
('credits', b'\xff not unicodable'),
|
("credits", b"\xff not unicodable"),
|
||||||
('credits_url', u'http://olé/not_ascii_unicode_url'),
|
("credits_url", "http://olé/not_ascii_unicode_url"),
|
||||||
('credits_url', b'http://host/\x81/not_ascii_bytes_url'),
|
("credits_url", b"http://host/\x81/not_ascii_bytes_url"),
|
||||||
('credits_url', 'not_absolute_url'),
|
("credits_url", "not_absolute_url"),
|
||||||
('dec_deg', -90.00001),
|
("dec_deg", -90.00001),
|
||||||
('dec_deg', 90.00001),
|
("dec_deg", 90.00001),
|
||||||
('dec_deg', NAN),
|
("dec_deg", NAN),
|
||||||
('dec_deg', INF),
|
("dec_deg", INF),
|
||||||
('dec_deg', 'not numeric'),
|
("dec_deg", "not numeric"),
|
||||||
('image_url', None),
|
("image_url", None),
|
||||||
('image_url', u'http://olé/not_ascii_unicode_url'),
|
("image_url", "http://olé/not_ascii_unicode_url"),
|
||||||
('image_url', b'http://host/\x81/not_ascii_bytes_url'),
|
("image_url", b"http://host/\x81/not_ascii_bytes_url"),
|
||||||
('image_url', 'not_absolute_url'),
|
("image_url", "not_absolute_url"),
|
||||||
('name', None),
|
("name", None),
|
||||||
('ra_deg', NAN),
|
("ra_deg", NAN),
|
||||||
('ra_deg', INF),
|
("ra_deg", INF),
|
||||||
('ra_deg', 'not numeric'),
|
("ra_deg", "not numeric"),
|
||||||
('reverse_parity', 1), # only bools allowed
|
("reverse_parity", 1), # only bools allowed
|
||||||
('reverse_parity', 't'), # only bools allowed
|
("reverse_parity", "t"), # only bools allowed
|
||||||
('rotation_deg', NAN),
|
("rotation_deg", NAN),
|
||||||
('rotation_deg', INF),
|
("rotation_deg", INF),
|
||||||
('rotation_deg', 'not numeric'),
|
("rotation_deg", "not numeric"),
|
||||||
('scale_arcsec', 0.),
|
("scale_arcsec", 0.0),
|
||||||
('scale_arcsec', NAN),
|
("scale_arcsec", NAN),
|
||||||
('scale_arcsec', INF),
|
("scale_arcsec", INF),
|
||||||
('scale_arcsec', 'not numeric'),
|
("scale_arcsec", "not numeric"),
|
||||||
('thumbnail_url', u'http://olé/not_ascii_unicode_url'),
|
("thumbnail_url", "http://olé/not_ascii_unicode_url"),
|
||||||
('thumbnail_url', b'http://host/\x81/not_ascii_bytes_url'),
|
("thumbnail_url", b"http://host/\x81/not_ascii_bytes_url"),
|
||||||
('thumbnail_url', 'not_absolute_url'),
|
("thumbnail_url", "not_absolute_url"),
|
||||||
('x_offset_pixels', NAN),
|
("x_offset_pixels", NAN),
|
||||||
('x_offset_pixels', INF),
|
("x_offset_pixels", INF),
|
||||||
('x_offset_pixels', 'not numeric'),
|
("x_offset_pixels", "not numeric"),
|
||||||
('y_offset_pixels', NAN),
|
("y_offset_pixels", NAN),
|
||||||
('y_offset_pixels', INF),
|
("y_offset_pixels", INF),
|
||||||
('y_offset_pixels', 'not numeric'),
|
("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):
|
def test_showimage_invalid_settings(showimage, attr, val):
|
||||||
setattr(showimage, attr, val)
|
setattr(showimage, attr, val)
|
||||||
assert showimage.invalidity_reason() is not None
|
assert showimage.invalidity_reason() is not None
|
||||||
|
|
||||||
|
|
||||||
SHOWIMAGE_GOOD_SETTINGS = [
|
SHOWIMAGE_GOOD_SETTINGS = [
|
||||||
('credits', b'unicodable bytes'),
|
("credits", b"unicodable bytes"),
|
||||||
('credits', u'unicode é'),
|
("credits", "unicode é"),
|
||||||
('credits', None),
|
("credits", None),
|
||||||
('credits_url', b'http://localhost/absolute_bytes_url'),
|
("credits_url", b"http://localhost/absolute_bytes_url"),
|
||||||
('credits_url', u'//localhost/absolute_unicode_url'),
|
("credits_url", "//localhost/absolute_unicode_url"),
|
||||||
('credits_url', None),
|
("credits_url", None),
|
||||||
('dec_deg', -90),
|
("dec_deg", -90),
|
||||||
('dec_deg', 90),
|
("dec_deg", 90),
|
||||||
('image_url', b'http://localhost/absolute_bytes_url'),
|
("image_url", b"http://localhost/absolute_bytes_url"),
|
||||||
('image_url', u'//localhost/absolute_unicode_url'),
|
("image_url", "//localhost/absolute_unicode_url"),
|
||||||
('name', b'unicodable bytes'),
|
("name", b"unicodable bytes"),
|
||||||
('name', u'unicode é'),
|
("name", "unicode é"),
|
||||||
('ra_deg', -720.),
|
("ra_deg", -720.0),
|
||||||
('ra_deg', 980.),
|
("ra_deg", 980.0),
|
||||||
('reverse_parity', False),
|
("reverse_parity", False),
|
||||||
('reverse_parity', True),
|
("reverse_parity", True),
|
||||||
('rotation_deg', -1),
|
("rotation_deg", -1),
|
||||||
('scale_arcsec', -1.),
|
("scale_arcsec", -1.0),
|
||||||
('thumbnail_url', b'http://localhost/absolute_bytes_url'),
|
("thumbnail_url", b"http://localhost/absolute_bytes_url"),
|
||||||
('thumbnail_url', u'//localhost/absolute_unicode_url'),
|
("thumbnail_url", "//localhost/absolute_unicode_url"),
|
||||||
('thumbnail_url', None),
|
("thumbnail_url", None),
|
||||||
('x_offset_pixels', -1.),
|
("x_offset_pixels", -1.0),
|
||||||
('x_offset_pixels', 0),
|
("x_offset_pixels", 0),
|
||||||
('y_offset_pixels', -1.),
|
("y_offset_pixels", -1.0),
|
||||||
('y_offset_pixels', 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):
|
def test_showimage_valid_settings(showimage, attr, val):
|
||||||
setattr(showimage, attr, val)
|
setattr(showimage, attr, val)
|
||||||
assert showimage.invalidity_reason() is None
|
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">
|
<Folder Name="{name}" Group="Goto">
|
||||||
<Place Name="{name}" RA="0" Dec="0" ZoomLevel="0" DataSetType="Sky" Opacity="100"
|
<Place Name="{name}" RA="0" Dec="0" ZoomLevel="0" DataSetType="Sky" Opacity="100"
|
||||||
Thumbnail="" Constellation="">
|
Thumbnail="" Constellation="">
|
||||||
|
@ -149,22 +169,29 @@ def _make_showimage_result(credurl='', name='name'):
|
||||||
<ImageSet DataSetType="Sky" BandPass="Visible" Url="http://localhost/image.jpg"
|
<ImageSet DataSetType="Sky" BandPass="Visible" Url="http://localhost/image.jpg"
|
||||||
TileLevels="0" WidthFactor="2" Rotation="0" Projection="SkyImage"
|
TileLevels="0" WidthFactor="2" Rotation="0" Projection="SkyImage"
|
||||||
FileType=".tif" CenterY="0" CenterX="0" BottomsUp="False" OffsetX="0"
|
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>
|
<Credits></Credits>
|
||||||
<CreditsUrl>{credurl}</CreditsUrl>
|
<CreditsUrl>{credurl}</CreditsUrl>
|
||||||
</ImageSet>
|
</ImageSet>
|
||||||
</ForegroundImageSet>
|
</ForegroundImageSet>
|
||||||
</Place>
|
</Place>
|
||||||
</Folder>
|
</Folder>
|
||||||
'''.format(credurl=credurl, name=name)
|
""".format(
|
||||||
|
credurl=credurl, name=name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SHOWIMAGE_RESULTS = [
|
SHOWIMAGE_RESULTS = [
|
||||||
(dict(), _make_showimage_result()),
|
(dict(), _make_showimage_result()),
|
||||||
(dict(name='test&xml"esc'), _make_showimage_result(name='test&xml"esc')),
|
(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(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):
|
def test_showimage_valid_settings(showimage, attrs, expected):
|
||||||
expected = ElementTree.fromstring(expected)
|
expected = ElementTree.fromstring(expected)
|
||||||
|
|
||||||
|
@ -179,88 +206,95 @@ def test_showimage_valid_settings(showimage, attrs, expected):
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tileimage(client):
|
def tileimage(client):
|
||||||
"Return a valid TileImage request object."
|
"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 = [
|
TILEIMAGE_BAD_SETTINGS = [
|
||||||
('credits', b'\xff not unicodable'),
|
("credits", b"\xff not unicodable"),
|
||||||
('credits_url', u'http://olé/not_ascii_unicode_url'),
|
("credits_url", "http://olé/not_ascii_unicode_url"),
|
||||||
('credits_url', b'http://host/\x81/not_ascii_bytes_url'),
|
("credits_url", b"http://host/\x81/not_ascii_bytes_url"),
|
||||||
('credits_url', 'not_absolute_url'),
|
("credits_url", "not_absolute_url"),
|
||||||
('dec_deg', -90.00001),
|
("dec_deg", -90.00001),
|
||||||
('dec_deg', 90.00001),
|
("dec_deg", 90.00001),
|
||||||
('dec_deg', NAN),
|
("dec_deg", NAN),
|
||||||
('dec_deg', INF),
|
("dec_deg", INF),
|
||||||
('dec_deg', 'not numeric'),
|
("dec_deg", "not numeric"),
|
||||||
('image_url', None),
|
("image_url", None),
|
||||||
('image_url', u'http://olé/not_ascii_unicode_url'),
|
("image_url", "http://olé/not_ascii_unicode_url"),
|
||||||
('image_url', b'http://host/\x81/not_ascii_bytes_url'),
|
("image_url", b"http://host/\x81/not_ascii_bytes_url"),
|
||||||
('image_url', 'not_absolute_url'),
|
("image_url", "not_absolute_url"),
|
||||||
('ra_deg', NAN),
|
("ra_deg", NAN),
|
||||||
('ra_deg', INF),
|
("ra_deg", INF),
|
||||||
('ra_deg', 'not numeric'),
|
("ra_deg", "not numeric"),
|
||||||
('rotation_deg', NAN),
|
("rotation_deg", NAN),
|
||||||
('rotation_deg', INF),
|
("rotation_deg", INF),
|
||||||
('rotation_deg', 'not numeric'),
|
("rotation_deg", "not numeric"),
|
||||||
('scale_deg', 0.),
|
("scale_deg", 0.0),
|
||||||
('scale_deg', NAN),
|
("scale_deg", NAN),
|
||||||
('scale_deg', INF),
|
("scale_deg", INF),
|
||||||
('scale_deg', 'not numeric'),
|
("scale_deg", "not numeric"),
|
||||||
('thumbnail_url', u'http://olé/not_ascii_unicode_url'),
|
("thumbnail_url", "http://olé/not_ascii_unicode_url"),
|
||||||
('thumbnail_url', b'http://host/\x81/not_ascii_bytes_url'),
|
("thumbnail_url", b"http://host/\x81/not_ascii_bytes_url"),
|
||||||
('thumbnail_url', 'not_absolute_url'),
|
("thumbnail_url", "not_absolute_url"),
|
||||||
('x_offset_deg', NAN),
|
("x_offset_deg", NAN),
|
||||||
('x_offset_deg', INF),
|
("x_offset_deg", INF),
|
||||||
('x_offset_deg', 'not numeric'),
|
("x_offset_deg", "not numeric"),
|
||||||
('y_offset_deg', NAN),
|
("y_offset_deg", NAN),
|
||||||
('y_offset_deg', INF),
|
("y_offset_deg", INF),
|
||||||
('y_offset_deg', 'not numeric'),
|
("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):
|
def test_tileimage_invalid_settings(tileimage, attr, val):
|
||||||
setattr(tileimage, attr, val)
|
setattr(tileimage, attr, val)
|
||||||
assert tileimage.invalidity_reason() is not None
|
assert tileimage.invalidity_reason() is not None
|
||||||
|
|
||||||
|
|
||||||
TILEIMAGE_GOOD_SETTINGS = [
|
TILEIMAGE_GOOD_SETTINGS = [
|
||||||
('credits', b'unicodable bytes'),
|
("credits", b"unicodable bytes"),
|
||||||
('credits', u'unicode é'),
|
("credits", "unicode é"),
|
||||||
('credits', None),
|
("credits", None),
|
||||||
('credits_url', b'http://localhost/absolute_bytes_url'),
|
("credits_url", b"http://localhost/absolute_bytes_url"),
|
||||||
('credits_url', u'//localhost/absolute_unicode_url'),
|
("credits_url", "//localhost/absolute_unicode_url"),
|
||||||
('credits_url', None),
|
("credits_url", None),
|
||||||
('dec_deg', 90),
|
("dec_deg", 90),
|
||||||
('dec_deg', 90),
|
("dec_deg", 90),
|
||||||
('dec_deg', None),
|
("dec_deg", None),
|
||||||
('image_url', b'http://localhost/absolute_bytes_url'),
|
("image_url", b"http://localhost/absolute_bytes_url"),
|
||||||
('image_url', u'//localhost/absolute_unicode_url'),
|
("image_url", "//localhost/absolute_unicode_url"),
|
||||||
('ra_deg', -720.),
|
("ra_deg", -720.0),
|
||||||
('ra_deg', 980.),
|
("ra_deg", 980.0),
|
||||||
('ra_deg', None),
|
("ra_deg", None),
|
||||||
('rotation_deg', -1),
|
("rotation_deg", -1),
|
||||||
('rotation_deg', None),
|
("rotation_deg", None),
|
||||||
('scale_deg', -1.),
|
("scale_deg", -1.0),
|
||||||
('scale_deg', None),
|
("scale_deg", None),
|
||||||
('thumbnail_url', b'http://localhost/absolute_bytes_url'),
|
("thumbnail_url", b"http://localhost/absolute_bytes_url"),
|
||||||
('thumbnail_url', u'//localhost/absolute_unicode_url'),
|
("thumbnail_url", "//localhost/absolute_unicode_url"),
|
||||||
('thumbnail_url', None),
|
("thumbnail_url", None),
|
||||||
('x_offset_deg', -1.),
|
("x_offset_deg", -1.0),
|
||||||
('x_offset_deg', 0),
|
("x_offset_deg", 0),
|
||||||
('x_offset_deg', None),
|
("x_offset_deg", None),
|
||||||
('y_offset_deg', -1.),
|
("y_offset_deg", -1.0),
|
||||||
('y_offset_deg', 0),
|
("y_offset_deg", 0),
|
||||||
('y_offset_deg', None),
|
("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):
|
def test_tileimage_valid_settings(tileimage, attr, val):
|
||||||
setattr(tileimage, attr, val)
|
setattr(tileimage, attr, val)
|
||||||
assert tileimage.invalidity_reason() is None
|
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"
|
<Place Name="{name}" RA="0" Dec="0" ZoomLevel="32768" DataSetType="Sky"
|
||||||
Opacity="100" Thumbnail="http://www.worldwidetelescope.org/wwtweb/tilethumb.aspx?name={ident}"
|
Opacity="100" Thumbnail="http://www.worldwidetelescope.org/wwtweb/tilethumb.aspx?name={ident}"
|
||||||
Constellation="">
|
Constellation="">
|
||||||
|
@ -277,18 +311,25 @@ def _make_tileimage_result(credits='', credurl='', ident=None, name='Image File'
|
||||||
</ForegroundImageSet>
|
</ForegroundImageSet>
|
||||||
</Place>
|
</Place>
|
||||||
</Folder>
|
</Folder>
|
||||||
'''.format(credits=credits, credurl=credurl, ident=ident, name=name)
|
""".format(
|
||||||
|
credits=credits, credurl=credurl, ident=ident, name=name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TILEIMAGE_RESULTS = [
|
TILEIMAGE_RESULTS = [
|
||||||
(dict(), _make_tileimage_result(
|
(
|
||||||
credits = ' NASA/JPL-Caltech',
|
dict(),
|
||||||
credurl = 'http://www.spitzer.caltech.edu/images/5259-sig12-011-The-Helix-Nebula-Unraveling-at-the-Seams',
|
_make_tileimage_result(
|
||||||
ident = '1176481368',
|
credits=" NASA/JPL-Caltech",
|
||||||
name = 'Helix Nebula',
|
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):
|
def test_tileimage_valid_settings(tileimage, attrs, expected):
|
||||||
expected = ElementTree.fromstring(expected)
|
expected = ElementTree.fromstring(expected)
|
||||||
|
|
||||||
|
|
Загрузка…
Ссылка в новой задаче