From ef6af839ad7710c63bb1b876c45a8dc65589e101 Mon Sep 17 00:00:00 2001 From: Rui Chen Date: Tue, 20 Sep 2016 17:19:56 +0800 Subject: [PATCH] Add HttpClient and OSC plugin Change-Id: I272dbaf877dbdfbfa7ebb67dbf2d9c3dd3d7246d --- nimbleclient/common/http.py | 357 ++++++++++++++++++++++++++++++++ nimbleclient/common/utils.py | 32 +++ nimbleclient/exceptions.py | 132 ++++++++++++ nimbleclient/i18n.py | 31 +++ nimbleclient/osc/__init__.py | 0 nimbleclient/osc/plugin.py | 73 +++++++ nimbleclient/osc/v1/__init__.py | 0 nimbleclient/v1/__init__.py | 0 nimbleclient/v1/client.py | 24 +++ requirements.txt | 8 +- setup.cfg | 7 + test-requirements.txt | 10 +- 12 files changed, 667 insertions(+), 7 deletions(-) create mode 100644 nimbleclient/common/http.py create mode 100644 nimbleclient/common/utils.py create mode 100644 nimbleclient/exceptions.py create mode 100644 nimbleclient/i18n.py create mode 100644 nimbleclient/osc/__init__.py create mode 100644 nimbleclient/osc/plugin.py create mode 100644 nimbleclient/osc/v1/__init__.py create mode 100644 nimbleclient/v1/__init__.py create mode 100644 nimbleclient/v1/client.py diff --git a/nimbleclient/common/http.py b/nimbleclient/common/http.py new file mode 100644 index 0000000..501e21b --- /dev/null +++ b/nimbleclient/common/http.py @@ -0,0 +1,357 @@ +# Copyright 2016 Huawei, Inc. All rights reserved. +# +# 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. +# + +import copy +import hashlib +import logging +import os +import socket + +from keystoneauth1 import adapter +from oslo_serialization import jsonutils +from oslo_utils import encodeutils +from oslo_utils import importutils +import requests +import six +from six.moves.urllib import parse + +from nimbleclient.common import utils +from nimbleclient import exceptions as exc +from nimbleclient.i18n import _ +from nimbleclient.i18n import _LW + +LOG = logging.getLogger(__name__) + +USER_AGENT = 'python-nimbleclient' +CHUNKSIZE = 1024 * 64 # 64kB +SENSITIVE_HEADERS = ('X-Auth-Token',) +osprofiler_web = importutils.try_import('osprofiler.web') + + +def authenticated_fetcher(hc): + """A wrapper around the nimble client object to fetch a template.""" + + def _do(*args, **kwargs): + if isinstance(hc.http_client, SessionClient): + method, url = args + return hc.http_client.request(url, method, **kwargs).content + else: + return hc.http_client.raw_request(*args, **kwargs).content + + return _do + + +def get_system_ca_file(): + """Return path to system default CA file.""" + # Standard CA file locations for Debian/Ubuntu, RedHat/Fedora, + # Suse, FreeBSD/OpenBSD, MacOSX, and the bundled ca + ca_path = ['/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', + '/etc/ssl/ca-bundle.pem', + '/etc/ssl/cert.pem', + '/System/Library/OpenSSL/certs/cacert.pem', + requests.certs.where()] + for ca in ca_path: + LOG.debug("Looking for ca file %s", ca) + if os.path.exists(ca): + LOG.debug("Using ca file %s", ca) + return ca + LOG.warning(_LW("System ca file could not be found.")) + + +class HTTPClient(object): + + def __init__(self, endpoint, **kwargs): + self.endpoint = endpoint + self.auth_url = kwargs.get('auth_url') + self.auth_token = kwargs.get('token') + self.username = kwargs.get('username') + self.password = kwargs.get('password') + self.region_name = kwargs.get('region_name') + self.include_pass = kwargs.get('include_pass') + self.endpoint_url = endpoint + + self.cert_file = kwargs.get('cert_file') + self.key_file = kwargs.get('key_file') + self.timeout = kwargs.get('timeout') + + self.ssl_connection_params = { + 'ca_file': kwargs.get('ca_file'), + 'cert_file': kwargs.get('cert_file'), + 'key_file': kwargs.get('key_file'), + 'insecure': kwargs.get('insecure'), + } + + self.verify_cert = None + if parse.urlparse(endpoint).scheme == "https": + if kwargs.get('insecure'): + self.verify_cert = False + else: + self.verify_cert = kwargs.get('ca_file', get_system_ca_file()) + + # FIXME(shardy): We need this for compatibility with the oslo apiclient + # we should move to inheriting this class from the oslo HTTPClient + self.last_request_id = None + + def safe_header(self, name, value): + if name in SENSITIVE_HEADERS: + # because in python3 byte string handling is ... ug + v = value.encode('utf-8') + h = hashlib.sha1(v) + d = h.hexdigest() + return encodeutils.safe_decode(name), "{SHA1}%s" % d + else: + return (encodeutils.safe_decode(name), + encodeutils.safe_decode(value)) + + def log_curl_request(self, method, url, kwargs): + curl = ['curl -g -i -X %s' % method] + + for (key, value) in kwargs['headers'].items(): + header = '-H \'%s: %s\'' % self.safe_header(key, value) + curl.append(header) + + conn_params_fmt = [ + ('key_file', '--key %s'), + ('cert_file', '--cert %s'), + ('ca_file', '--cacert %s'), + ] + for (key, fmt) in conn_params_fmt: + value = self.ssl_connection_params.get(key) + if value: + curl.append(fmt % value) + + if self.ssl_connection_params.get('insecure'): + curl.append('-k') + + if 'data' in kwargs: + curl.append('-d \'%s\'' % kwargs['data']) + + curl.append('%s%s' % (self.endpoint, url)) + LOG.debug(' '.join(curl)) + + @staticmethod + def log_http_response(resp): + status = (resp.raw.version / 10.0, resp.status_code, resp.reason) + dump = ['\nHTTP/%.1f %s %s' % status] + dump.extend(['%s: %s' % (k, v) for k, v in resp.headers.items()]) + dump.append('') + if resp.content: + content = resp.content + if isinstance(content, six.binary_type): + content = content.decode() + dump.extend([content, '']) + LOG.debug('\n'.join(dump)) + + def _http_request(self, url, method, **kwargs): + """Send an http request with the specified characteristics. + + Wrapper around requests.request to handle tasks such as + setting headers and error handling. + """ + # Copy the kwargs so we can reuse the original in case of redirects + kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {})) + kwargs['headers'].setdefault('User-Agent', USER_AGENT) + if self.auth_token: + kwargs['headers'].setdefault('X-Auth-Token', self.auth_token) + else: + kwargs['headers'].update(self.credentials_headers()) + if self.auth_url: + kwargs['headers'].setdefault('X-Auth-Url', self.auth_url) + if self.region_name: + kwargs['headers'].setdefault('X-Region-Name', self.region_name) + if self.include_pass and 'X-Auth-Key' not in kwargs['headers']: + kwargs['headers'].update(self.credentials_headers()) + if osprofiler_web: + kwargs['headers'].update(osprofiler_web.get_trace_id_headers()) + + self.log_curl_request(method, url, kwargs) + + if self.cert_file and self.key_file: + kwargs['cert'] = (self.cert_file, self.key_file) + + if self.verify_cert is not None: + kwargs['verify'] = self.verify_cert + + if self.timeout is not None: + kwargs['timeout'] = float(self.timeout) + + # Allow caller to specify not to follow redirects, in which case we + # just return the redirect response. Useful for using stacks:lookup. + redirect = kwargs.pop('redirect', True) + + # Since requests does not follow the RFC when doing redirection to sent + # back the same method on a redirect we are simply bypassing it. For + # example if we do a DELETE/POST/PUT on a URL and we get a 302 RFC says + # that we should follow that URL with the same method as before, + # requests doesn't follow that and send a GET instead for the method. + # Hopefully this could be fixed as they say in a comment in a future + # point version i.e.: 3.x + # See issue: https://github.com/kennethreitz/requests/issues/1704 + allow_redirects = False + + # Use fully qualified URL from response header for redirects + if not parse.urlparse(url).netloc: + url = self.endpoint_url + url + + try: + resp = requests.request( + method, + url, + allow_redirects=allow_redirects, + **kwargs) + except socket.gaierror as e: + message = (_("Error finding address for %(url)s: %(e)s") % + {'url': self.endpoint_url + url, 'e': e}) + raise exc.InvalidEndpoint(message=message) + except (socket.error, socket.timeout) as e: + endpoint = self.endpoint + message = (_("Error communicating with %(endpoint)s %(e)s") % + {'endpoint': endpoint, 'e': e}) + raise exc.CommunicationError(message=message) + + self.log_http_response(resp) + + if not ('X-Auth-Key' in kwargs['headers']) and ( + resp.status_code == 401 or + (resp.status_code == 500 and "(HTTP 401)" in resp.content)): + raise exc.HTTPUnauthorized(_("Authentication failed: %s") + % resp.content) + elif 400 <= resp.status_code < 600: + raise exc.from_response(resp) + elif resp.status_code in (301, 302, 305): + # Redirected. Reissue the request to the new location, + # unless caller specified redirect=False + if redirect: + location = resp.headers.get('location') + if not location: + message = _("Location not returned with redirect") + raise exc.InvalidEndpoint(message=message) + resp = self._http_request(location, method, **kwargs) + elif resp.status_code == 300: + raise exc.from_response(resp) + + return resp + + def credentials_headers(self): + creds = {} + # NOTE(dhu): (shardy) When deferred_auth_method=password, Heat + # encrypts and stores username/password. For Keystone v3, the + # intent is to use trusts since SHARDY is working towards + # deferred_auth_method=trusts as the default. + # TODO(dhu): Make Keystone v3 work in Heat standalone mode. Maye + # require X-Auth-User-Domain. + if self.username: + creds['X-Auth-User'] = self.username + if self.password: + creds['X-Auth-Key'] = self.password + return creds + + def json_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + + if 'data' in kwargs: + kwargs['data'] = jsonutils.dumps(kwargs['data']) + + resp = self._http_request(url, method, **kwargs) + body = utils.get_response_body(resp) + return resp, body + + def raw_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', + 'application/octet-stream') + return self._http_request(url, method, **kwargs) + + def client_request(self, method, url, **kwargs): + resp, body = self.json_request(method, url, **kwargs) + return resp + + def head(self, url, **kwargs): + return self.client_request("HEAD", url, **kwargs) + + def get(self, url, **kwargs): + return self.client_request("GET", url, **kwargs) + + def post(self, url, **kwargs): + return self.client_request("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self.client_request("PUT", url, **kwargs) + + def delete(self, url, **kwargs): + return self.raw_request("DELETE", url, **kwargs) + + def patch(self, url, **kwargs): + return self.client_request("PATCH", url, **kwargs) + + +class SessionClient(adapter.LegacyJsonAdapter): + """HTTP client based on Keystone client session.""" + + def request(self, url, method, **kwargs): + redirect = kwargs.get('redirect') + kwargs.setdefault('user_agent', USER_AGENT) + + if 'data' in kwargs: + kwargs['data'] = jsonutils.dumps(kwargs['data']) + + resp, body = super(SessionClient, self).request( + url, method, + raise_exc=False, + **kwargs) + + if 400 <= resp.status_code < 600: + raise exc.from_response(resp) + elif resp.status_code in (301, 302, 305): + if redirect: + location = resp.headers.get('location') + path = self.strip_endpoint(location) + resp = self.request(path, method, **kwargs) + elif resp.status_code == 300: + raise exc.from_response(resp) + + return resp + + def credentials_headers(self): + return {} + + def strip_endpoint(self, location): + if location is None: + message = _("Location not returned with 302") + raise exc.InvalidEndpoint(message=message) + if (self.endpoint_override is not None and + location.lower().startswith(self.endpoint_override.lower())): + return location[len(self.endpoint_override):] + else: + return location + + +def _construct_http_client(endpoint=None, username=None, password=None, + include_pass=None, endpoint_type=None, + auth_url=None, **kwargs): + session = kwargs.pop('session', None) + auth = kwargs.pop('auth', None) + + if session: + kwargs['endpoint_override'] = endpoint + return SessionClient(session, auth=auth, **kwargs) + else: + return HTTPClient(endpoint=endpoint, username=username, + password=password, include_pass=include_pass, + endpoint_type=endpoint_type, auth_url=auth_url, + **kwargs) diff --git a/nimbleclient/common/utils.py b/nimbleclient/common/utils.py new file mode 100644 index 0000000..e1ef8b9 --- /dev/null +++ b/nimbleclient/common/utils.py @@ -0,0 +1,32 @@ +# Copyright 2016 Huawei, Inc. All rights reserved. +# +# 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. +# + +import logging + +from nimbleclient.i18n import _LE + +LOG = logging.getLogger(__name__) + + +def get_response_body(resp): + body = resp.content + if 'application/json' in resp.headers.get('content-type', ''): + try: + body = resp.json() + except ValueError: + LOG.error(_LE('Could not decode response body as JSON')) + else: + body = None + return body diff --git a/nimbleclient/exceptions.py b/nimbleclient/exceptions.py new file mode 100644 index 0000000..c2bc9dc --- /dev/null +++ b/nimbleclient/exceptions.py @@ -0,0 +1,132 @@ +# Copyright 2016 Huawei, Inc. All rights reserved. +# +# 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. +# + +import sys + +from oslo_serialization import jsonutils +from oslo_utils import reflection + +from nimbleclient.i18n import _ + +verbose = 0 + + +class BaseException(Exception): + """An error occurred.""" + def __init__(self, message=None): + self.message = message + + def __str__(self): + return self.message or self.__class__.__doc__ + + +class CommandError(BaseException): + """Invalid usage of CLI.""" + + +class InvalidEndpoint(BaseException): + """The provided endpoint is invalid.""" + + +class CommunicationError(BaseException): + """Unable to communicate with server.""" + + +class HTTPException(BaseException): + """Base exception for all HTTP-derived exceptions.""" + code = 'N/A' + + def __init__(self, message=None, code=None): + super(HTTPException, self).__init__(message) + try: + self.error = jsonutils.loads(message) + if 'error' not in self.error: + raise KeyError(_('Key "error" not exists')) + except KeyError: + # NOTE(RuiChen): If key 'error' happens not exist, + # self.message becomes no sense. In this case, we + # return doc of current exception class instead. + self.error = {'error': + {'message': self.__class__.__doc__}} + except Exception: + self.error = {'error': + {'message': self.message or self.__class__.__doc__}} + if self.code == "N/A" and code is not None: + self.code = code + + def __str__(self): + message = self.error['error'].get('message', 'Internal Error') + if verbose: + traceback = self.error['error'].get('traceback', '') + return (_('ERROR: %(message)s\n%(traceback)s') % + {'message': message, 'traceback': traceback}) + else: + return _('ERROR: %s') % message + + +class HTTPMultipleChoices(HTTPException): + code = 300 + + def __str__(self): + self.details = _("Requested version of Heat API is not" + "available.") + return (_("%(name)s (HTTP %(code)s) %(details)s") % + { + 'name': reflection.get_class_name(self, fully_qualified=False), + 'code': self.code, + 'details': self.details}) + + +class Unauthorized(HTTPException): + code = 401 + + +class HTTPUnauthorized(Unauthorized): + pass + + +class HTTPMethodNotAllowed(HTTPException): + code = 405 + + +class HTTPUnsupported(HTTPException): + code = 415 + + +class HTTPInternalServerError(HTTPException): + code = 500 + + +class HTTPNotImplemented(HTTPException): + code = 501 + + +class HTTPBadGateway(HTTPException): + code = 502 + + +# NOTE(RuiChen): Build a mapping of HTTP codes to corresponding exception +# classes +_code_map = {} +for obj_name in dir(sys.modules[__name__]): + if obj_name.startswith('HTTP'): + obj = getattr(sys.modules[__name__], obj_name) + _code_map[obj.code] = obj + + +def from_response(response): + """Return an instance of an HTTPException based on requests response.""" + cls = _code_map.get(response.status_code, HTTPException) + return cls(response.content, response.status_code) diff --git a/nimbleclient/i18n.py b/nimbleclient/i18n.py new file mode 100644 index 0000000..7788abb --- /dev/null +++ b/nimbleclient/i18n.py @@ -0,0 +1,31 @@ +# Copyright 2016 Huawei, Inc. All rights reserved. +# +# 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. +# + +import oslo_i18n + +_translators = oslo_i18n.TranslatorFactory(domain='nimbleclient') + +# The primary translation function using the well-known name "_" +_ = _translators.primary + +# Translators for log levels. +# +# The abbreviated names are meant to reflect the usual use of a short +# name like '_'. The "L" is for "log" and the other letter comes from +# the level. +_LI = _translators.log_info +_LW = _translators.log_warning +_LE = _translators.log_error +_LC = _translators.log_critical diff --git a/nimbleclient/osc/__init__.py b/nimbleclient/osc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nimbleclient/osc/plugin.py b/nimbleclient/osc/plugin.py new file mode 100644 index 0000000..ce27cd4 --- /dev/null +++ b/nimbleclient/osc/plugin.py @@ -0,0 +1,73 @@ +# Copyright 2016 Huawei, Inc. All rights reserved. +# +# 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. +# + +import logging + +from osc_lib import utils + +from nimbleclient.i18n import _ + +LOG = logging.getLogger(__name__) + +DEFAULT_BAREMETAL_COMPUTE_API_VERSION = '1' +API_VERSION_OPTION = 'os_baremetal_compute_api_version' +API_NAME = 'baremetal_compute' +API_VERSIONS = { + '1': 'nimbleclient.v1.client.Client', +} + + +def make_client(instance): + """Returns an baremetal service client""" + nimble_client = utils.get_client_class( + API_NAME, + instance._api_version[API_NAME], + API_VERSIONS) + LOG.debug('Instantiating baremetal-compute client: %s', nimble_client) + + endpoint = instance.get_endpoint_for_service_type( + API_NAME, + region_name=instance.region_name, + interface=instance.interface, + ) + + kwargs = {'endpoint': endpoint, + 'auth_url': instance.auth.auth_url, + 'region_name': instance.region_name, + 'username': instance.auth_ref.username} + + if instance.session: + kwargs.update(session=instance.session) + else: + kwargs.update(token=instance.auth_ref.auth_token) + + client = nimble_client(**kwargs) + + return client + + +def build_option_parser(parser): + """Hook to add global options""" + parser.add_argument( + '--os-baremetal-compute-api-version', + metavar='', + default=utils.env( + 'OS_BAREMETAL_COMPUTE_API_VERSION', + default=DEFAULT_BAREMETAL_COMPUTE_API_VERSION), + help=(_('Baremetal compute API version, default=%s ' + '(Env: OS_BAREMETAL_COMPUTE_API_VERSION)') % + DEFAULT_BAREMETAL_COMPUTE_API_VERSION) + ) + return parser diff --git a/nimbleclient/osc/v1/__init__.py b/nimbleclient/osc/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nimbleclient/v1/__init__.py b/nimbleclient/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nimbleclient/v1/client.py b/nimbleclient/v1/client.py new file mode 100644 index 0000000..05e61ef --- /dev/null +++ b/nimbleclient/v1/client.py @@ -0,0 +1,24 @@ +# Copyright 2016 Huawei, Inc. All rights reserved. +# +# 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. +# + +from nimbleclient.common import http + + +class Client(object): + """Client for the Nimble v1 API.""" + + def __init__(self, *args, **kwargs): + """Initialize a new client for the Nimble v1 API.""" + self.http_client = http._construct_http_client(*args, **kwargs) diff --git a/requirements.txt b/requirements.txt index ab17530..23c38e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,11 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. +keystoneauth1>=2.10.0 # Apache-2.0 +osc-lib>=1.0.2 # Apache-2.0 +oslo.i18n>=2.1.0 # Apache-2.0 +oslo.serialization>=1.10.0 # Apache-2.0 +oslo.utils>=3.16.0 # Apache-2.0 pbr>=1.6 # Apache-2.0 -osc-lib>=1.0.2 # Apache-2.0 \ No newline at end of file +requests>=2.10.0 # Apache-2.0 +six>=1.9.0 # MIT diff --git a/setup.cfg b/setup.cfg index 35079e4..7b2588c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,13 @@ classifier = packages = nimbleclient +[entry_points] +openstack.cli.extension = + baremetal_compute = nimbleclient.osc.plugin + +openstack.baremetal_compute.v1 = + + [build_sphinx] source-dir = doc/source build-dir = doc/build diff --git a/test-requirements.txt b/test-requirements.txt index a3fcd81..ef6b400 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -2,16 +2,14 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. -hacking<0.12,>=0.11.0 # Apache-2.0 - coverage>=3.6 # Apache-2.0 +hacking<0.12,>=0.11.0 # Apache-2.0 +python-openstackclient>=2.1.0 # Apache-2.0 python-subunit>=0.0.18 # Apache-2.0/BSD -sphinx!=1.3b1,<1.3,>=1.2.1 # BSD oslosphinx!=3.4.0,>=2.5.0 # Apache-2.0 oslotest>=1.10.0 # Apache-2.0 +reno>=1.8.0 # Apache2 +sphinx!=1.3b1,<1.3,>=1.2.1 # BSD testrepository>=0.0.18 # Apache-2.0/BSD testscenarios>=0.4 # Apache-2.0/BSD testtools>=1.4.0 # MIT - -# releasenotes -reno>=1.8.0 # Apache2