253 lines
8.2 KiB
Python
253 lines
8.2 KiB
Python
![]() |
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||
|
# not use this file except in compliance with the License. You may obtain
|
||
|
# a copy of the License at
|
||
|
#
|
||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||
|
#
|
||
|
# Unless required by applicable law or agreed to in writing, software
|
||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||
|
# License for the specific language governing permissions and limitations
|
||
|
# under the License.
|
||
|
|
||
|
"""The passive components to version discovery.
|
||
|
|
||
|
The Discover object in discover.py contains functions that can create objects
|
||
|
on your behalf. These functions are not usable from within the keystoneclient
|
||
|
library because you will get dependency resolution issues.
|
||
|
|
||
|
The Discover object in this file provides the querying components of Discovery.
|
||
|
This includes functions like url_for which allow you to retrieve URLs and the
|
||
|
raw data specified in version discovery responses.
|
||
|
"""
|
||
|
|
||
|
import logging
|
||
|
|
||
|
from keystoneclient import exceptions
|
||
|
|
||
|
|
||
|
_LOGGER = logging.getLogger(__name__)
|
||
|
|
||
|
|
||
|
def get_version_data(session, url):
|
||
|
"""Retrieve raw version data from a url."""
|
||
|
headers = {'Accept': 'application/json'}
|
||
|
|
||
|
resp = session.get(url, headers=headers)
|
||
|
|
||
|
try:
|
||
|
body_resp = resp.json()
|
||
|
except ValueError:
|
||
|
pass
|
||
|
else:
|
||
|
# In the event of querying a root URL we will get back a list of
|
||
|
# available versions.
|
||
|
try:
|
||
|
return body_resp['versions']['values']
|
||
|
except (KeyError, TypeError):
|
||
|
pass
|
||
|
|
||
|
# Most servers don't have a 'values' element so accept a simple
|
||
|
# versions dict if available.
|
||
|
try:
|
||
|
return body_resp['versions']
|
||
|
except KeyError:
|
||
|
pass
|
||
|
|
||
|
# Otherwise if we query an endpoint like /v2.0 then we will get back
|
||
|
# just the one available version.
|
||
|
try:
|
||
|
return [body_resp['version']]
|
||
|
except KeyError:
|
||
|
pass
|
||
|
|
||
|
err_text = resp.text[:50] + '...' if len(resp.text) > 50 else resp.text
|
||
|
msg = 'Invalid Response - Bad version data returned: %s' % err_text
|
||
|
raise exceptions.DiscoveryFailure(msg)
|
||
|
|
||
|
|
||
|
def normalize_version_number(version):
|
||
|
"""Turn a version representation into a tuple."""
|
||
|
|
||
|
# trim the v from a 'v2.0' or similar
|
||
|
try:
|
||
|
version = version.lstrip('v')
|
||
|
except AttributeError:
|
||
|
pass
|
||
|
|
||
|
# if it's an integer or a numeric as a string then normalize it
|
||
|
# to a string, this ensures 1 decimal point
|
||
|
try:
|
||
|
num = float(version)
|
||
|
except Exception:
|
||
|
pass
|
||
|
else:
|
||
|
version = str(num)
|
||
|
|
||
|
# if it's a string (or an integer) from above break it on .
|
||
|
try:
|
||
|
return tuple(map(int, version.split('.')))
|
||
|
except Exception:
|
||
|
pass
|
||
|
|
||
|
# last attempt, maybe it's a list or iterable.
|
||
|
try:
|
||
|
return tuple(map(int, version))
|
||
|
except Exception:
|
||
|
pass
|
||
|
|
||
|
raise TypeError('Invalid version specified: %s' % version)
|
||
|
|
||
|
|
||
|
def version_match(required, candidate):
|
||
|
"""Test that an available version is a suitable match for a required
|
||
|
version.
|
||
|
|
||
|
To be suitable a version must be of the same major version as required
|
||
|
and be at least a match in minor/patch level.
|
||
|
|
||
|
eg. 3.3 is a match for a required 3.1 but 4.1 is not.
|
||
|
|
||
|
:param tuple required: the version that must be met.
|
||
|
:param tuple candidate: the version to test against required.
|
||
|
|
||
|
:returns bool: True if candidate is suitable False otherwise.
|
||
|
"""
|
||
|
# major versions must be the same (e.g. even though v2 is a lower
|
||
|
# version than v3 we can't use it if v2 was requested)
|
||
|
if candidate[0] != required[0]:
|
||
|
return False
|
||
|
|
||
|
# prevent selecting a minor version less than what is required
|
||
|
if candidate < required:
|
||
|
return False
|
||
|
|
||
|
return True
|
||
|
|
||
|
|
||
|
class Discover(object):
|
||
|
|
||
|
CURRENT_STATUSES = ('stable', 'current', 'supported')
|
||
|
DEPRECATED_STATUSES = ('deprecated',)
|
||
|
EXPERIMENTAL_STATUSES = ('experimental',)
|
||
|
|
||
|
def __init__(self, session, url):
|
||
|
self._data = get_version_data(session, url)
|
||
|
|
||
|
def raw_version_data(self, allow_experimental=False,
|
||
|
allow_deprecated=True, allow_unknown=False):
|
||
|
"""Get raw version information from URL.
|
||
|
|
||
|
Raw data indicates that only minimal validation processing is performed
|
||
|
on the data, so what is returned here will be the data in the same
|
||
|
format it was received from the endpoint.
|
||
|
|
||
|
:param bool allow_experimental: Allow experimental version endpoints.
|
||
|
:param bool allow_deprecated: Allow deprecated version endpoints.
|
||
|
:param bool allow_unknown: Allow endpoints with an unrecognised status.
|
||
|
|
||
|
:returns list: The endpoints returned from the server that match the
|
||
|
criteria.
|
||
|
"""
|
||
|
versions = []
|
||
|
for v in self._data:
|
||
|
try:
|
||
|
status = v['status']
|
||
|
except KeyError:
|
||
|
_LOGGER.warning('Skipping over invalid version data. '
|
||
|
'No stability status in version.')
|
||
|
continue
|
||
|
|
||
|
status = status.lower()
|
||
|
|
||
|
if status in self.CURRENT_STATUSES:
|
||
|
versions.append(v)
|
||
|
elif status in self.DEPRECATED_STATUSES:
|
||
|
if allow_deprecated:
|
||
|
versions.append(v)
|
||
|
elif status in self.EXPERIMENTAL_STATUSES:
|
||
|
if allow_experimental:
|
||
|
versions.append(v)
|
||
|
elif allow_unknown:
|
||
|
versions.append(v)
|
||
|
|
||
|
return versions
|
||
|
|
||
|
def version_data(self, **kwargs):
|
||
|
"""Get normalized version data.
|
||
|
|
||
|
Return version data in a structured way.
|
||
|
|
||
|
:returns list(dict): A list of version data dictionaries sorted by
|
||
|
version number. Each data element in the returned
|
||
|
list is a dictionary consisting of at least:
|
||
|
|
||
|
:version tuple: The normalized version of the endpoint.
|
||
|
:url str: The url for the endpoint.
|
||
|
:raw_status str: The status as provided by the server
|
||
|
"""
|
||
|
data = self.raw_version_data(**kwargs)
|
||
|
versions = []
|
||
|
|
||
|
for v in data:
|
||
|
try:
|
||
|
version_str = v['id']
|
||
|
except KeyError:
|
||
|
_LOGGER.info('Skipping invalid version data. Missing ID.')
|
||
|
continue
|
||
|
|
||
|
try:
|
||
|
links = v['links']
|
||
|
except KeyError:
|
||
|
_LOGGER.info('Skipping invalid version data. Missing links')
|
||
|
continue
|
||
|
|
||
|
version_number = normalize_version_number(version_str)
|
||
|
|
||
|
for link in links:
|
||
|
try:
|
||
|
rel = link['rel']
|
||
|
url = link['href']
|
||
|
except (KeyError, TypeError):
|
||
|
_LOGGER.info('Skipping invalid version link. '
|
||
|
'Missing link URL or relationship.')
|
||
|
continue
|
||
|
|
||
|
if rel.lower() == 'self':
|
||
|
break
|
||
|
else:
|
||
|
_LOGGER.info('Skipping invalid version data. '
|
||
|
'Missing link to endpoint.')
|
||
|
continue
|
||
|
|
||
|
versions.append({'version': version_number,
|
||
|
'url': url,
|
||
|
'raw_status': v['status']})
|
||
|
|
||
|
versions.sort(key=lambda v: v['version'])
|
||
|
return versions
|
||
|
|
||
|
def data_for(self, version, **kwargs):
|
||
|
"""Return endpoint data for a specific version.
|
||
|
|
||
|
:returns dict: the endpoint data for a URL that matches the required
|
||
|
version (the format is described in version_data)
|
||
|
or None if no match.
|
||
|
"""
|
||
|
version = normalize_version_number(version)
|
||
|
version_data = self.version_data(**kwargs)
|
||
|
|
||
|
for data in reversed(version_data):
|
||
|
if version_match(version, data['version']):
|
||
|
return data
|
||
|
|
||
|
return None
|
||
|
|
||
|
def url_for(self, version, **kwargs):
|
||
|
"""Get the endpoint url for a required version.
|
||
|
|
||
|
:returns str: The url for the specified version or None if no match.
|
||
|
"""
|
||
|
data = self.data_for(version, **kwargs)
|
||
|
return data['url'] if data else None
|