From 49b44934b973d38737a6b2c0fb9a0fc17e74a4a8 Mon Sep 17 00:00:00 2001 From: Chris Dent Date: Tue, 22 Mar 2016 17:48:42 +0000 Subject: [PATCH] Initial proof of concept of microversion_parse See README.rst for details. The basic gist is that a get_version method is provided. It takes a dict or list of headers and returned a version for a service_type if it can find it. --- .testr.conf | 4 + README.md | 2 - README.rst | 29 +++ microversion_parse/__init__.py | 106 +++++++++ microversion_parse/tests/__init__.py | 0 microversion_parse/tests/test_get_version.py | 225 +++++++++++++++++++ requirements.txt | 1 + setup.cfg | 27 +++ setup.py | 18 ++ test-requirements.txt | 3 + tox.ini | 40 ++++ 11 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 .testr.conf delete mode 100644 README.md create mode 100644 README.rst create mode 100644 microversion_parse/__init__.py create mode 100644 microversion_parse/tests/__init__.py create mode 100644 microversion_parse/tests/test_get_version.py create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 test-requirements.txt create mode 100644 tox.ini diff --git a/.testr.conf b/.testr.conf new file mode 100644 index 0000000..ac83491 --- /dev/null +++ b/.testr.conf @@ -0,0 +1,4 @@ +[DEFAULT] +test_command=${PYTHON:-python} -m subunit.run discover -t . ${OS_TEST_PATH:-microversion_parse} $LISTOPT $IDOPTION +test_id_option=--load-list $IDFILE +test_list_option=--list diff --git a/README.md b/README.md deleted file mode 100644 index 3f0b1a9..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# microversion_parse -A simple parser for OpenStack microversion headers diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..769a17c --- /dev/null +++ b/README.rst @@ -0,0 +1,29 @@ +microversion_parse +================= + +A simple parser for OpenStack microversion headers:: + + import microversion_parse + + # headers is a dict of headers with folded (comma-separated + # values) or a list of header, value tuples + version = microversion_parse.get_version( + headers, service_type='compute', legacy_type='nova') + +It processes microversion headers with the standard form:: + + OpenStack-API-Version: compute 2.1 + +It also deals with several older formats, depending on the values of +the service_type and legacy_type arguments:: + + OpenStack-compute-api-version: 2.1 + OpenStack-nova-api-version: 2.1 + X-OpenStack-nova-api-version: 2.1 + +.. note:: The X prefixed version does not currently parse for + service type named headers, only project named headers. + +If a version string cannot be found ``None`` will be returned. If +the input is incorrect usual Python exceptions (ValueError, +TypeError) are allowed to raise to the caller. diff --git a/microversion_parse/__init__.py b/microversion_parse/__init__.py new file mode 100644 index 0000000..c864fb5 --- /dev/null +++ b/microversion_parse/__init__.py @@ -0,0 +1,106 @@ +# 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 collections + + +STANDARD_HEADER = 'openstack-api-version' + + +def get_version(headers, service_type=None, legacy_type=None): + """Parse a microversion out of headers + + :param headers: The headers of a request, dict or list + :param service_type: The service type being looked for in the headers + :param legacy_type: The project name to use when looking for fallback + headers. + :returns: a version string or "latest" + :raises: ValueError + """ + # If headers is not a dict we assume is an iterator of + # tuple-like headers, which we will fold into a dict. + # + # The flow is that we first look for the new standard singular + # header: + # * openstack-api-version: + # If that's not present we fall back, in order, to: + # * openstack--api-version: + # * openstack--api-version: + # * x-openstack--api-version: + # + # Folded headers are joined by , + folded_headers = fold_headers(headers) + + version = check_standard_header(folded_headers, service_type) + if version: + return version + + extra_headers = build_headers(service_type, legacy_type) + version = check_legacy_headers(folded_headers, extra_headers) + return version + + +def build_headers(service_type, legacy_type=None): + """Create the headers to be looked at.""" + headers = [ + 'openstack-%s-api-version' % service_type + ] + if legacy_type: + legacy_headers = [ + 'openstack-%s-api-version' % legacy_type, + 'x-openstack-%s-api-version' % legacy_type + ] + headers.extend(legacy_headers) + return headers + + +def check_legacy_headers(headers, legacy_headers): + """Gather values from old headers.""" + for legacy_header in legacy_headers: + try: + value = headers[legacy_header] + return value.split(',')[-1].strip() + except KeyError: + pass + return None + + +def check_standard_header(headers, service_type): + """Parse the standard header to get value for service.""" + try: + header = headers[STANDARD_HEADER] + for header_value in reversed(header.split(',')): + try: + service, version = header_value.strip().split(None, 1) + if service.lower() == service_type.lower(): + return version.strip() + except ValueError: + pass + except (KeyError, ValueError): + return None + + +def fold_headers(headers): + """Turn a list of headers into a folded dict.""" + if isinstance(headers, dict): + # TODO(cdent): canonicalize? (i.e. in lower()) + return headers + header_dict = collections.defaultdict(list) + for header, value in headers: + header_dict[header.lower()].append(value.strip()) + + folded_headers = {} + for header, value in header_dict.items(): + folded_headers[header] = ','.join(value) + + return folded_headers diff --git a/microversion_parse/tests/__init__.py b/microversion_parse/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/microversion_parse/tests/test_get_version.py b/microversion_parse/tests/test_get_version.py new file mode 100644 index 0000000..3d9abef --- /dev/null +++ b/microversion_parse/tests/test_get_version.py @@ -0,0 +1,225 @@ +# 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 testtools + +import microversion_parse + + +class TestBuildHeaders(testtools.TestCase): + + def test_build_header_list_service(self): + headers = microversion_parse.build_headers('alpha') + + self.assertEqual(1, len(headers)) + self.assertEqual('openstack-alpha-api-version', headers[0]) + + def test_build_header_list_legacy(self): + headers = microversion_parse.build_headers('alpha', 'beta') + + self.assertEqual(3, len(headers)) + self.assertEqual('openstack-alpha-api-version', headers[0]) + self.assertEqual('openstack-beta-api-version', headers[1]) + self.assertEqual('x-openstack-beta-api-version', headers[2]) + + +class TestFoldHeaders(testtools.TestCase): + + def test_dict_headers(self): + headers = { + 'header-one': 'alpha', + 'header-two': 'beta', + 'header-three': 'gamma', + } + + folded_headers = microversion_parse.fold_headers(headers) + self.assertEqual(3, len(folded_headers)) + self.assertEqual(set(headers.keys()), set(folded_headers.keys())) + self.assertEqual('gamma', folded_headers['header-three']) + + def test_listed_tuple_headers(self): + headers = [ + ('header-one', 'alpha'), + ('header-two', 'beta'), + ('header-one', 'gamma'), + ] + + folded_headers = microversion_parse.fold_headers(headers) + self.assertEqual(2, len(folded_headers)) + self.assertEqual(set(['header-one', 'header-two']), + set(folded_headers.keys())) + self.assertEqual('alpha,gamma', folded_headers['header-one']) + + def test_bad_headers(self): + headers = 'wow this is not a headers' + self.assertRaises(ValueError, microversion_parse.fold_headers, + headers) + + # TODO(cdent): Test with request objects from frameworks. + + +class TestStandardHeader(testtools.TestCase): + + def test_simple_match(self): + headers = { + 'header-one': 'alpha', + 'openstack-api-version': 'compute 2.1', + 'header-two': 'beta', + } + version = microversion_parse.check_standard_header(headers, 'compute') + # TODO(cdent): String or number. Choosing string for now + # since 'latest' is always a string. + self.assertEqual('2.1', version) + + def test_match_extra_whitespace(self): + headers = { + 'header-one': 'alpha', + 'openstack-api-version': ' compute 2.1 ', + 'header-two': 'beta', + } + version = microversion_parse.check_standard_header(headers, 'compute') + self.assertEqual('2.1', version) + + def test_no_match_no_value(self): + headers = { + 'header-one': 'alpha', + 'openstack-api-version': 'compute ', + 'header-two': 'beta', + } + version = microversion_parse.check_standard_header(headers, 'compute') + self.assertEqual(None, version) + + def test_no_match_wrong_service(self): + headers = { + 'header-one': 'alpha', + 'openstack-api-version': 'network 5.9 ', + 'header-two': 'beta', + } + version = microversion_parse.check_standard_header( + headers, 'compute') + self.assertEqual(None, version) + + def test_match_multiple_services(self): + headers = { + 'header-one': 'alpha', + 'openstack-api-version': 'network 5.9 ,compute 2.1,telemetry 7.8', + 'header-two': 'beta', + } + version = microversion_parse.check_standard_header( + headers, 'compute') + self.assertEqual('2.1', version) + version = microversion_parse.check_standard_header( + headers, 'telemetry') + self.assertEqual('7.8', version) + + def test_match_multiple_same_service(self): + headers = { + 'header-one': 'alpha', + 'openstack-api-version': 'compute 5.9 ,compute 2.1,compute 7.8', + 'header-two': 'beta', + } + version = microversion_parse.check_standard_header( + headers, 'compute') + self.assertEqual('7.8', version) + + +class TestLegacyHeaders(testtools.TestCase): + + def test_legacy_headers_straight(self): + headers = { + 'header-one': 'alpha', + 'openstack-compute-api-version': ' 2.1 ', + 'header-two': 'beta', + } + version = microversion_parse.get_version( + headers, service_type='compute') + self.assertEqual('2.1', version) + + def test_legacy_headers_folded(self): + headers = { + 'header-one': 'alpha', + 'openstack-compute-api-version': ' 2.1, 9.2 ', + 'header-two': 'beta', + } + version = microversion_parse.get_version( + headers, service_type='compute') + self.assertEqual('9.2', version) + + def test_older_legacy_headers_with_service(self): + headers = { + 'header-one': 'alpha', + 'x-openstack-compute-api-version': ' 2.1, 9.2 ', + 'header-two': 'beta', + } + version = microversion_parse.get_version( + headers, service_type='compute') + # We don't do x- for service types. + self.assertEqual(None, version) + + def test_legacy_headers_project(self): + headers = { + 'header-one': 'alpha', + 'x-openstack-nova-api-version': ' 2.1, 9.2 ', + 'header-two': 'beta', + } + version = microversion_parse.get_version( + headers, service_type='compute', legacy_type='nova') + self.assertEqual('9.2', version) + + def test_legacy_headers_prefer(self): + headers = { + 'header-one': 'alpha', + 'openstack-compute-api-version': '3.7', + 'x-openstack-nova-api-version': ' 2.1, 9.2 ', + 'header-two': 'beta', + } + version = microversion_parse.get_version( + headers, service_type='compute', legacy_type='nova') + self.assertEqual('3.7', version) + + +class TestGetHeaders(testtools.TestCase): + + def test_preference(self): + headers = { + 'header-one': 'alpha', + 'openstack-api-version': 'compute 11.12, telemetry 9.7', + 'openstack-compute-api-version': '3.7', + 'x-openstack-nova-api-version': ' 2.1, 9.2 ', + 'header-two': 'beta', + } + version = microversion_parse.get_version( + headers, service_type='compute', legacy_type='nova') + self.assertEqual('11.12', version) + + def test_unfolded_service(self): + headers = [ + ('header-one', 'alpha'), + ('openstack-api-version', 'compute 1.0'), + ('openstack-api-version', 'compute 2.0'), + ('openstack-api-version', '3.0'), + ] + version = microversion_parse.get_version( + headers, service_type='compute', legacy_type='nova') + self.assertEqual('2.0', version) + + def test_unfolded_in_name(self): + headers = [ + ('header-one', 'alpha'), + ('openstack-compute-api-version', '1.0'), + ('openstack-compute-api-version', '2.0'), + ('openstack-telemetry-api-version', '3.0'), + ] + version = microversion_parse.get_version( + headers, service_type='compute', legacy_type='nova') + self.assertEqual('2.0', version) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3752a9f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,27 @@ +[metadata] +name = microversion_parse +summary = OpenStack microversion heaader parser +description-file = README.rst +author = OpenStack +author-email = openstack-dev@lists.openstack.org +home-page = http://www.openstack.org/ +classifier = + Environment :: OpenStack + Intended Audience :: Information Technology + License :: OSI Approved :: Apache Software License + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 + +[files] +packages = + microversion_parse + +[build_sphinx] +all_files = 1 +build-dir = docs/build +source-dir = docs/source diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7f1bbe8 --- /dev/null +++ b/setup.py @@ -0,0 +1,18 @@ +# 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 setuptools + +setuptools.setup( + setup_requires=['pbr'], + pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..f1262a7 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,3 @@ +testtools +testrepository +coverage diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..d9b88bc --- /dev/null +++ b/tox.ini @@ -0,0 +1,40 @@ +[tox] +minversion = 2.0 +skipsdist = True +# If you want pypy or pypy3, do 'tox -epypy,pypy3', it might work! +# And you can get coverage with 'tox -ecover'. +envlist = py27,py34,py35,pep8 + +[testenv] +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +install_command = pip install -U {opts} {packages} +setenv = OS_TEST_PATH=microversion_parse/tests/ +usedevelop = True +commands = python setup.py testr --testr-args="{posargs}" + +[testenv:venv] +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = {posargs} + +[testenv:pep8] +deps = hacking +usedevelop = False +commands = + flake8 + +[testenv:cover] +commands = python setup.py testr --coverage --testr-args="{posargs}" + +[testenv:docs] +commands = + rm -rf doc/build + python setup.py build_sphinx +whitelist_externals = + rm + +[flake8] +ignore = H405,E126 +exclude=.venv,.git,.tox,dist,*egg,*.egg-info,build,examples,docs +show-source = True