From ae0ef7f6ee50d95482e8ec24aaaaeeada33ab6f2 Mon Sep 17 00:00:00 2001 From: Sam Van Oort Date: Mon, 14 Dec 2015 12:45:58 -0500 Subject: [PATCH] Python 3 alpha compat: unit tests pass Also includes some significant refactoring of tests --- pyresttest/benchmarks.py | 48 ++++++++++++---------- pyresttest/contenthandling.py | 14 ++++--- pyresttest/functionaltest.py | 13 +++++- pyresttest/generators.py | 6 +-- pyresttest/parsing.py | 11 +++-- pyresttest/resttest.py | 10 +++-- pyresttest/test_tests.py | 77 +++++++++++++++-------------------- pyresttest/test_validators.py | 17 +++++++- pyresttest/tests.py | 38 +++++++++-------- pyresttest/validators.py | 10 ++++- sample_extension.py | 10 +++-- 11 files changed, 148 insertions(+), 106 deletions(-) diff --git a/pyresttest/benchmarks.py b/pyresttest/benchmarks.py index 21426f1f..fb199f32 100644 --- a/pyresttest/benchmarks.py +++ b/pyresttest/benchmarks.py @@ -6,10 +6,13 @@ import sys from parsing import * +# Python 2/3 switches +if sys.version_info[0] > 2: + from past.builtins import basestring + # Python 3 compatibility shims from six import binary_type from six import text_type -from six import string_types """ Encapsulates logic related to benchmarking @@ -106,7 +109,7 @@ def std_deviation(array): try: len(variance) except TypeError: # Python 3.3 workaround until can use the statistics module from 3.4 - variance = [variance] + variance = list(variance) stdev = AGGREGATES['mean_arithmetic'](variance) return math.sqrt(stdev) @@ -221,45 +224,46 @@ def parse_benchmark(base_url, node): if format in OUTPUT_FORMATS: benchmark.output_format = format else: - raise Exception('Invalid benchmark output format: ' + format) + raise ValueError('Invalid benchmark output format: ' + format) elif key == u'output_file': - if not isinstance(value, string_types): - raise Exception("Invalid output file format") + if not isinstance(value, basestring): + raise ValueError("Invalid output file format") benchmark.output_file = value elif key == u'metrics': - if isinstance(value, string_types): + if isinstance(value, basestring): # Single value - benchmark.add_metric(text_type(value, 'UTF-8')) + benchmark.add_metric(tests.coerce_to_string(value)) + # FIXME refactor the parsing of metrics here, lots of duplicated logic elif isinstance(value, list) or isinstance(value, set): # List of single values or list of {metric:aggregate, ...} for metric in value: if isinstance(metric, dict): for metricname, aggregate in metric.items(): - if not isinstance(metricname, string_types): - raise Exception( + if not isinstance(metricname, basestring): + raise TypeError( "Invalid metric input: non-string metric name") - if not isinstance(aggregate, string_types): - raise Exception( + if not isinstance(aggregate, basestring): + raise TypeError( "Invalid aggregate input: non-string aggregate name") # TODO unicode-safe this - benchmark.add_metric( - text_type(metricname, 'UTF-8'), text_type(aggregate, 'UTF-8')) + benchmark.add_metric(tests.coerce_to_string(metricname), + tests.coerce_to_string(aggregate)) - elif isinstance(metric, string_types): - benchmark.add_metric(text_type(metric, 'UTF-8')) + elif isinstance(metric, basestring): + benchmark.add_metric(tests.coerce_to_string(metric)) elif isinstance(value, dict): # Dictionary of metric-aggregate pairs for metricname, aggregate in value.items(): - if not isinstance(metricname, string_types): - raise Exception( + if not isinstance(metricname, basestring): + raise TypeError( "Invalid metric input: non-string metric name") - if not isinstance(aggregate, string_types): - raise Exception( + if not isinstance(aggregate, basestring): + raise TypeError( "Invalid aggregate input: non-string aggregate name") - benchmark.add_metric( - text_type(metricname, 'UTF-8'), text_type(aggregate, 'UTF-8')) + benchmark.add_metric(tests.coerce_to_string(metricname), + test.coerce_to_string(aggregate)) else: - raise Exception( + raise TypeError( "Invalid benchmark metric datatype: " + str(value)) return benchmark diff --git a/pyresttest/contenthandling.py b/pyresttest/contenthandling.py index 86b38705..b53d336a 100644 --- a/pyresttest/contenthandling.py +++ b/pyresttest/contenthandling.py @@ -2,8 +2,10 @@ import sys from parsing import * -# Python 3 compatibility -from six import string_types +# Python 2/3 switches +PYTHON_MAJOR_VERSION = sys.version_info[0] +if PYTHON_MAJOR_VERSION > 2: + from past.builtins import basestring """ @@ -68,7 +70,7 @@ def create_noread_version(self): def setup(self, input, is_file=False, is_template_path=False, is_template_content=False): """ Self explanatory, input is inline content or file path. """ - if not isinstance(input, string_types): + if not isinstance(input, basestring): raise TypeError("Input is not a string") if is_file: input = os.path.abspath(input) @@ -99,7 +101,7 @@ def parse_content(node): while (node and not is_done): # Dive through the configuration tree # Finally we've found the value! - if isinstance(node, string_types): + if isinstance(node, basestring): output.content = node output.setup(node, is_file=is_file, is_template_path=is_template_path, is_template_content=is_template_content) @@ -114,7 +116,7 @@ def parse_content(node): flat = lowercase_keys(flatten_dictionaries(node)) for key, value in flat.items(): if key == u'template': - if isinstance(value, string_types): + if isinstance(value, basestring): if is_file: value = os.path.abspath(value) output.content = value @@ -130,7 +132,7 @@ def parse_content(node): break elif key == 'file': - if isinstance(value, string_types): + if isinstance(value, basestring): output.content = os.path.abspath(value) output.is_file = True output.is_template_content = is_template_content diff --git a/pyresttest/functionaltest.py b/pyresttest/functionaltest.py index a9559ccd..0af5d127 100644 --- a/pyresttest/functionaltest.py +++ b/pyresttest/functionaltest.py @@ -14,6 +14,10 @@ import resttest import validators +# Python 2/3 compat shims +from six import text_type +from six import binary_type + # Django testing settings, initial configuration os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") djangopath = os.path.join(os.path.dirname( @@ -230,8 +234,13 @@ def test_post(self): test2.url = self.prefix + '/api/person/?login=theadmiral' test_response2 = resttest.run_test(test2) self.assertTrue(test_response2.passed) - obj = json.loads(str(test_response2.body)) - print(json.dumps(obj)) + + # Test JSON load/dump round trip on body + bod = test_response2.body + if isinstance(bod, binary_type): + bod = text_type(bod, 'utf-8') + print(json.dumps(json.loads(bod))) + def test_delete(self): """ Try removing an item """ diff --git a/pyresttest/generators.py b/pyresttest/generators.py index fded038c..fe4a86eb 100644 --- a/pyresttest/generators.py +++ b/pyresttest/generators.py @@ -8,9 +8,9 @@ import parsing # Python 3 compatibility -if sys.version_info[0] == 3: +if sys.version_info[0] > 2: from builtins import range as xrange -from six import string_types + from past.builtins import basestring """ Collection of generators to be used in templating for test data @@ -206,7 +206,7 @@ def register_generator(typename, parse_function): typename is the new generator type name (must not already exist) parse_function will parse a configuration object (dict) """ - if not isinstance(typename, string_types): + if not isinstance(typename, basestring): raise TypeError( 'Generator type name {0} is invalid, must be a string'.format(typename)) if typename in GENERATOR_TYPES: diff --git a/pyresttest/parsing.py b/pyresttest/parsing.py index d84a97de..aef3c072 100644 --- a/pyresttest/parsing.py +++ b/pyresttest/parsing.py @@ -4,8 +4,11 @@ # Python 3 compatibility shims from six import binary_type from six import text_type -from six import string_types + +# Python 2/3 switches PYTHON_MAJOR_VERSION = sys.version_info[0] +if PYTHON_MAJOR_VERSION > 2: + from past.builtins import basestring """ Parsing utilities, pulled out so they can be used in multiple modules @@ -15,7 +18,7 @@ def encode_unicode_bytes(my_string): """ Shim function, converts Unicode to UTF-8 encoded bytes regardless of the source format Intended for python 3 compatibility mode, and b/c PyCurl only takes raw bytes """ - if not isinstance(my_string, string_types): + if not isinstance(my_string, basestring): my_string = repr(my_string) # TODO refactor this to use six types @@ -86,9 +89,9 @@ def safe_to_bool(input): If it's not a boolean or string that matches 'false' or 'true' when ignoring case, throws an exception """ if isinstance(input, bool): return input - elif isinstance(input, string_types) and input.lower() == u'false': + elif isinstance(input, basestring) and input.lower() == u'false': return False - elif isinstance(input, string_types) and input.lower() == u'true': + elif isinstance(input, basestring) and input.lower() == u'true': return True else: raise TypeError( diff --git a/pyresttest/resttest.py b/pyresttest/resttest.py index 70be15f4..73462e55 100644 --- a/pyresttest/resttest.py +++ b/pyresttest/resttest.py @@ -20,10 +20,14 @@ except ImportError: from io import BytesIO as MyIO +ESCAPE_DECODING = 'string-escape' # Python 3 compatibility -if sys.version_info[0] == 3: +if sys.version_info[0] > 2: from past.builtins import basestring from builtins import range as xrange + ESCAPE_DECODING = 'unicode_escape' + +from six import text_type # Pyresttest internals from binding import Context @@ -327,7 +331,7 @@ def run_test(mytest, test_config=TestConfig(), context=None): # Retrieve values result.body = body.getvalue() body.close() - result.response_headers = headers.getvalue() + result.response_headers = text_type(headers.getvalue(), 'ISO-8859-1') # Per RFC 2616 headers.close() response_code = curl.getinfo(pycurl.RESPONSE_CODE) @@ -390,7 +394,7 @@ def run_test(mytest, test_config=TestConfig(), context=None): if test_config.print_bodies or not result.passed: if test_config.interactive: print("RESPONSE:") - print(result.body.decode("string-escape")) + print(result.body.decode(ESCAPE_DECODING)) if test_config.print_headers or not result.passed: if test_config.interactive: diff --git a/pyresttest/test_tests.py b/pyresttest/test_tests.py index 4590a644..bb485b7d 100644 --- a/pyresttest/test_tests.py +++ b/pyresttest/test_tests.py @@ -6,16 +6,16 @@ from binding import Context from contenthandling import ContentHandler import generators -try: - import mock -except: - from unittest import mock +PYTHON_MAJOR_VERSION = sys.version_info[0] +if PYTHON_MAJOR_VERSION > 2: + from unittest import mock +else: + import mock # Python 3 compatibility shims from six import binary_type from six import text_type -from six import string_types class TestsTest(unittest.TestCase): """ Testing for basic REST test methods, how meta! """ @@ -26,39 +26,24 @@ def test_coerce_to_string(self): self.assertEqual(u'stuff', coerce_to_string(u'stuff')) self.assertEqual(u'stuff', coerce_to_string('stuff')) self.assertEqual(u'st😽uff', coerce_to_string(u'st😽uff')) + self.assertRaises(TypeError, coerce_to_string, {'key': 'value'}) + self.assertRaises(TypeError, coerce_to_string, None) - try: - blah = coerce_to_string({'key': 'value'}) - self.fail('Coercing to string should fail given a non-string or integer type') - except: - pass - - try: - blah = coerce_to_string(None) - self.fail('Coercing to string should fail given a None type') - except: - pass - def test_coerce_string_to_ascii(self): - self.assertEqual(binary_type('stuff'), coerce_string_to_ascii(u'stuff')) + def test_coerce_http_method(self): + self.assertEqual(u'HEAD', coerce_http_method(u'hEaD')) + self.assertEqual(u'HEAD', coerce_http_method(b'hEaD')) + self.assertRaises(TypeError, coerce_http_method, 5) + self.assertRaises(TypeError, coerce_http_method, None) + self.assertRaises(TypeError, coerce_http_method, u'') - try: - blah = coerce_string_to_ascii(u'st😽uff') - self.fail('Coercing to ASCII string should fail for non-ASCII data') - except: - pass - try: - blah = coerce_string_to_ascii(1) - self.fail('Coercing to ASCII string should fail given a non-string type') - except: - pass + def test_coerce_string_to_ascii(self): + self.assertEqual(b'stuff', coerce_string_to_ascii(u'stuff')) + self.assertRaises(UnicodeEncodeError, coerce_string_to_ascii, u'st😽uff') + self.assertRaises(TypeError, coerce_string_to_ascii, 1) + self.assertRaises(TypeError, coerce_string_to_ascii, None) - try: - blah = coerce_string_to_ascii(None) - self.fail('Coercing to string should fail given a None type') - except: - pass def test_coerce_list_of_ints(self): self.assertEqual([1], coerce_list_of_ints(1)) @@ -92,17 +77,17 @@ def test_parse_test(self): # 204 response code myinput = {"url": "/ping", "meThod": "POST"} test = Test.parse_test('', myinput) - self.assertTrue(test.url == myinput['url']) - self.assertTrue(test.method == myinput['meThod']) - self.assertTrue(test.expected_status == [200, 201, 204]) + self.assertEqual(test.url, myinput['url']) + self.assertEqual(test.method, myinput['meThod']) + self.assertEqual(test.expected_status, [200, 201, 204]) # Authentication myinput = {"url": "/ping", "method": "GET", "auth_username": "foo", "auth_password": "bar"} test = Test.parse_test('', myinput) - self.assertTrue(test.auth_username == myinput['auth_username']) - self.assertTrue(test.auth_password == myinput['auth_password']) - self.assertTrue(test.expected_status == [200]) + self.assertEqual('foo', myinput['auth_username']) + self.assertEqual('bar', myinput['auth_password']) + self.assertEqual(test.expected_status, [200]) # Test that headers propagate myinput = {"url": "/ping", "method": "GET", @@ -111,9 +96,9 @@ def test_parse_test(self): expected_headers = {"Accept": "application/json", "Accept-Encoding": "gzip"} - self.assertTrue(test.url == myinput['url']) - self.assertTrue(test.method == 'GET') - self.assertTrue(test.expected_status == [200]) + self.assertEqual(test.url, myinput['url']) + self.assertEqual(test.method, 'GET') + self.assertEqual(test.expected_status, [200]) self.assertTrue(isinstance(test.headers, dict)) # Test no header mappings differ @@ -124,8 +109,8 @@ def test_parse_test(self): myinput = [{"url": "/ping"}, {"name": "cheese"}, {"expected_status": ["200", 204, "202"]}] test = Test.parse_test('', myinput) - self.assertTrue(test.name == "cheese") - self.assertTrue(test.expected_status == [200, 204, 202]) + self.assertEqual(test.name, "cheese") + self.assertEqual(test.expected_status, [200, 204, 202]) self.assertFalse(test.is_context_modifier()) def test_parse_nonstandard_http_method(self): @@ -173,13 +158,17 @@ def test_parse_custom_curl(self): except ValueError: pass + @unittest.skipIf(PYTHON_MAJOR_VERSION > 2, + reason="In python 3 we can't override the setopt method this way or by setattr, so mocks fail") def test_use_custom_curl(self): """ Test that test method really does configure correctly """ test = Test() test.curl_options = {'FOLLOWLOCATION': True, 'MAXREDIRS': 5} mock_handle = pycurl.Curl() + mock_handle.setopt = mock.MagicMock(return_value=True) test.configure_curl(curl_handle=mock_handle) + # print mock_handle.setopt.call_args_list # Debugging mock_handle.setopt.assert_any_call(mock_handle.FOLLOWLOCATION, True) mock_handle.setopt.assert_any_call(mock_handle.MAXREDIRS, 5) diff --git a/pyresttest/test_validators.py b/pyresttest/test_validators.py index c3baceca..e3435ddf 100644 --- a/pyresttest/test_validators.py +++ b/pyresttest/test_validators.py @@ -7,6 +7,11 @@ class ValidatorsTest(unittest.TestCase): """ Testing for validators and extract functions """ + def test_failure_obj(self): + """ Tests the basic boolean override here """ + myfailure = validators.Failure() + self.assertFalse(myfailure) + def test_contains_operators(self): """ Tests the contains / contained_by """ cont_func = validators.COMPARATORS['contains'] @@ -269,7 +274,11 @@ def test_raw_body_extractor(self): self.assertTrue(extractor.is_body_extractor) self.assertFalse(extractor.is_header_extractor) - bod = 'j1j21io312j3' + bod = u'j1j21io312j3' + val = extractor.extract(body=bod, headers='') + self.assertEqual(bod, val) + + bod = b'j1j21io312j3' val = extractor.extract(body=bod, headers='') self.assertEqual(bod, val) @@ -357,7 +366,11 @@ def test_get_extractor(self): 'expected': 3 } extractor = validators._get_extractor(config) - myjson = '{"key": {"val": 3}}' + myjson = u'{"key": {"val": 3}}' + extracted = extractor.extract(body=myjson) + self.assertEqual(3, extracted) + + myjson = b'{"key": {"val": 3}}' extracted = extractor.extract(body=myjson) self.assertEqual(3, extracted) diff --git a/pyresttest/tests.py b/pyresttest/tests.py index cf9520d0..994cfab1 100644 --- a/pyresttest/tests.py +++ b/pyresttest/tests.py @@ -18,15 +18,18 @@ from io import BytesIO as MyIO # Python 2/3 switches -if sys.version_info[0] > 2: +PYTHON_MAJOR_VERSION = sys.version_info[0] +if PYTHON_MAJOR_VERSION > 2: import urllib.parse as urlparse + from past.builtins import basestring else: import urlparse # Python 3 compatibility shims from six import binary_type from six import text_type -from six import string_types +from six import iteritems +from six.moves import filter as ifilter """ Pull out the Test objects and logic associated with them @@ -67,11 +70,12 @@ def coerce_string_to_ascii(val): raise TypeError("Input {0} is not a string, string expected".format(val)) def coerce_http_method(val): - try: - assert isinstance(val, string_types) and len(val) > 0 - except AssertionError: + myval = val + if not isinstance(myval, basestring) or len(val) == 0: raise TypeError("Invalid HTTP method name: input {0} is not a string or has 0 length".format(val)) - return text_type(val, 'UTF-8').upper() + if isinstance(myval, binary_type): + myval = myval.decode('utf-8') + return myval.upper() def coerce_list_of_ints(val): """ If single value, try to parse as integer, else try to parse as list of integer """ @@ -147,7 +151,7 @@ def get_body(self, context=None): """ Read body from file, applying template if pertinent """ if self._body is None: return None - elif isinstance(self._body, string_types): + elif isinstance(self._body, basestring): return self._body else: return self._body.get_content(context=context) @@ -348,7 +352,8 @@ def configure_curl(self, timeout=DEFAULT_TIMEOUT, context=None, curl_handle=None # Set custom curl options, which are KEY:VALUE pairs matching the pycurl option names # And the key/value pairs are set if self.curl_options: - for (key, value) in filter(lambda x: x[0] is not None and x[1] is not None, self.curl_options.items()): + filterfunc = lambda x: x[0] is not None and x[1] is not None # Must have key and value + for (key, value) in ifilter(filterfunc, self.curl_options.items()): # getattr to look up constant for variable name curl.setopt(getattr(curl, key), value) return curl @@ -427,11 +432,11 @@ def use_config_parser(configobject, configelement, configvalue): if isinstance(configvalue, dict): # Template is used for URL val = lowercase_keys(configvalue)[u'template'] - assert isinstance(val, string_types) or isinstance(val, int) + assert isinstance(val, basestring) or isinstance(val, int) url = urlparse.urljoin(base_url, coerce_to_string(val)) mytest.set_url(url, isTemplate=True) else: - assert isinstance(configvalue, string_types) or isinstance( + assert isinstance(configvalue, basestring) or isinstance( configvalue, int) mytest.url = urlparse.urljoin(base_url, coerce_to_string(configvalue)) elif configelement == u'extract_binds': @@ -448,10 +453,11 @@ def use_config_parser(configobject, configelement, configvalue): if len(extractor) > 1: raise ValueError( "Cannot define multiple extractors for given variable name") - extractor_type, extractor_config = extractor.items()[0] - extractor = validators.parse_extractor( - extractor_type, extractor_config) - mytest.extract_binds[variable_name] = extractor + + # Safe because length can only be 1 + for extractor_type, extractor_config in extractor.items(): + mytest.extract_binds[variable_name] = validators.parse_extractor(extractor_type, extractor_config) + elif configelement == u'validators': # Add a list of validators @@ -476,8 +482,8 @@ def use_config_parser(configobject, configelement, configvalue): configvalue = flatten_dictionaries(configvalue) if isinstance(configvalue, dict): - templates = filter(lambda x: str( - x[0]).lower() == 'template', configvalue.items()) + filterfunc = lambda x: str(x[0]).lower() == 'template' # Templated items + templates = [x for x in ifilter(filterfunc, configvalue.items())] else: templates = None diff --git a/pyresttest/validators.py b/pyresttest/validators.py index a8d7ccb7..f95bb39b 100644 --- a/pyresttest/validators.py +++ b/pyresttest/validators.py @@ -8,8 +8,13 @@ import re import sys +# Python 3 compatibility shims +from six import binary_type +from six import text_type + # Python 3 compatibility -if sys.version_info[0] > 2: +PYTHON_MAJOR_VERSION = sys.version_info[0] +if PYTHON_MAJOR_VERSION > 2: from past.builtins import basestring from past.builtins import long @@ -224,6 +229,9 @@ class MiniJsonExtractor(AbstractExtractor): is_body_extractor = True def extract_internal(self, query=None, args=None, body=None, headers=None): + if PYTHON_MAJOR_VERSION > 2 and isinstance(body, binary_type): + body = text_type(body, 'utf-8') # Default JSON encoding + try: body = json.loads(body) return self.query_dictionary(query, body) diff --git a/sample_extension.py b/sample_extension.py index 86fdc5e9..fd3d049c 100755 --- a/sample_extension.py +++ b/sample_extension.py @@ -4,16 +4,20 @@ import sys # Python 3 compatibility -if sys.version_info[0] == 3: +if sys.version_info[0] > 2: from past.builtins import basestring - +from pyresttest.six import text_type +from pyresttest.six import binary_type class ContainsValidator(validators.AbstractValidator): # Sample validator that verifies a string is contained in the request body contains_string = None def validate(self, body=None, headers=None, context=None): - result = self.contains_string in body + if isinstance(body, binary_type) and isinstance(self.contains_string, text_type): + result = self.contains_string.encode('utf-8') in body + else: + result = self.contains_string in body if result: return True else: # Return failure object with additional information