changeset 5653:ba67e397f063

Fix string/bytes issues under python 3. 1) cgi/client.py: override cgi.FieldStorage's make_file so that file is always created in binary/byte mode. This means that json (and xml) are bytes not strings. 2) rest.py: try harder to find dicttoxml in roundup directory or on sys.path. This just worked under python 2 but python 3 only searches sys.path by default and does not search relative like python 2. 3) rest.py: replace headers.getheader call removed from python 3 with equivalent code. 4) rest.py: make value returned from dispatch into bytes not string. 5) test/caseinsensitivedict.py, test/test_CaseInsensitiveDict.py: get code from stackoverflow that implements a case insensitive key dict. So dict['foo'], dict['Foo'] are the same entry. Used for looking up headers in mocked http rewuset header array. 6) test/rest_common.py: rework tests for etags and rest to properly supply bytes to the called routines. Calls to s2b and b2s and use of BytesIO and overriding make_file in cgi.FieldStorage to try to make sure it works under python 3.
author John Rouillard <rouilj@ieee.org>
date Sun, 17 Mar 2019 19:28:26 -0400
parents 9689d1bf9bb0
children 207e0f5d551c
files roundup/cgi/client.py roundup/rest.py test/rest_common.py test/test_CaseInsensitiveDict.py
diffstat 4 files changed, 169 insertions(+), 67 deletions(-) [+]
line wrap: on
line diff
--- a/roundup/cgi/client.py	Sun Mar 17 19:00:43 2019 -0400
+++ b/roundup/cgi/client.py	Sun Mar 17 19:28:26 2019 -0400
@@ -375,7 +375,18 @@
                     logger.debug("Setting CONTENT_LENGTH to 0 for method: %s",
                                 self.env['REQUEST_METHOD'])
 
+            # cgi.FieldStorage must save all data as
+            # binary/bytes. This is needed for handling json and xml
+            # data blobs under python 3. Under python 2, str and binary
+            # are interchangable, not so under 3.
+            def make_file(self):
+                import tempfile
+                return tempfile.TemporaryFile("wb+")
+
+            saved_make_file = cgi.FieldStorage.make_file
+            cgi.FieldStorage.make_file = make_file
             self.form = cgi.FieldStorage(fp=request.rfile, environ=env)
+            cgi.FieldStorage.make_file = saved_make_file
             # In some case (e.g. content-type application/xml), cgi
             # will not parse anything. Fake a list property in this case
             if self.form.list is None:
--- a/roundup/rest.py	Sun Mar 17 19:00:43 2019 -0400
+++ b/roundup/rest.py	Sun Mar 17 19:28:26 2019 -0400
@@ -20,9 +20,15 @@
 import re
 
 try:
-    from dicttoxml import dicttoxml
+    # if dicttoxml installed in roundup directory, use it
+    from .dicttoxml import dicttoxml
 except ImportError:
-    dicttoxml = None
+    try:
+        # else look in sys.path
+        from dicttoxml import dicttoxml
+    except ImportError:
+        # else not supported
+        dicttoxml = None
 
 from roundup import hyperdb
 from roundup import date
@@ -160,7 +166,8 @@
     etags = []
     if '@etag' in input:
         etags.append(input['@etag'].value);
-    etags.append(headers.getheader("ETag", None))
+    if "ETag" in headers:
+        etags.append(headers["ETag"])
     return etags
 
 def parse_accept_header(accept):
@@ -220,7 +227,9 @@
             else:
                 media_params.append((key, value))
         result.append((media_type, dict(media_params), q))
-    result.sort(lambda x, y: -cmp(x[2], y[2]))
+    # was: result.sort(lambda x, y: -cmp(x[2], y[2]))
+    # change for python 3 support
+    result.sort(key=lambda x: x[2], reverse=True)
     return result
 
 
@@ -1299,7 +1308,9 @@
         headers = self.client.request.headers
         # Never allow GET to be an unsafe operation (i.e. data changing).
         # User must use POST to "tunnel" DELETE, PUT, OPTIONS etc.
-        override = headers.getheader('X-HTTP-Method-Override')
+        override = None
+        if 'X-HTTP-Method-Override' in headers:
+            override = headers['X-HTTP-Method-Override']
         output = None
         if override:
             if method.upper() != 'GET':
@@ -1314,7 +1325,9 @@
                     uri)
 
         # parse Accept header and get the content type
-        accept_header = parse_accept_header(headers.getheader('Accept'))
+        accept_header = []
+        if 'Accept' in headers:
+            accept_header = parse_accept_header(headers['Accept'])
         accept_type = "invalid"
         for part in accept_header:
             if part[0] in self.__accepted_content_type:
@@ -1350,7 +1363,9 @@
         # Is there an input.value with format json data?
         # If so turn it into an object that emulates enough
         # of the FieldStorge methods/props to allow a response.
-        content_type_header = headers.getheader('Content-Type', None)
+        content_type_header = None
+        if 'Content-Type' in headers:
+            content_type_header = headers['Content-Type']
         if type(input.value) == str and content_type_header:
             parsed_content_type_header = content_type_header
             # the structure of a content-type header
@@ -1404,7 +1419,7 @@
 
         # Make output json end in a newline to
         # separate from following text in logs etc..
-        return output + "\n"
+        return bs2b(output + "\n")
 
 
 class RoundupJSONEncoder(json.JSONEncoder):
--- a/test/rest_common.py	Sun Mar 17 19:00:43 2019 -0400
+++ b/test/rest_common.py	Sun Mar 17 19:28:26 2019 -0400
@@ -8,15 +8,18 @@
 from roundup.rest import RestfulInstance, calculate_etag
 from roundup.backends import list_backends
 from roundup.cgi import client
+from roundup.anypy.strings import s2b, b2s
 import random
 
 from .db_test_base import setupTracker
 
 from .mocknull import MockNull
 
-from io import StringIO
+from io import BytesIO
 import json
 
+from .caseinsensitivedict import CaseInsensitiveDict
+
 NEEDS_INSTANCE = 1
 
 
@@ -68,7 +71,7 @@
             'TRACKER_NAME': 'rounduptest'
         }
         self.dummy_client = client.Client(self.instance, MockNull(), env, [], None)
-        self.dummy_client.request.headers.getheader = self.get_header
+        self.dummy_client.request.headers = CaseInsensitiveDict()
         self.empty_form = cgi.FieldStorage()
 
         self.server = RestfulInstance(self.dummy_client, self.db)
@@ -81,12 +84,6 @@
             if error.errno not in (errno.ENOENT, errno.ESRCH):
                 raise
 
-    def get_header (self, header, not_found=None):
-        try:
-            return self.headers[header.lower()]
-        except (AttributeError, KeyError):
-            return not_found
-
     def testGet(self):
         """
         Retrieve all three users
@@ -351,14 +348,15 @@
 
         Run over header only, etag in form only, both,
         each one broke and no etag. Use the put command
-        to triger the etag checking code.
+        to trigger the etag checking code.
         '''
         for mode in ('header', 'etag', 'both',
                      'brokenheader', 'brokenetag', 'none'):
             try:
-                # clean up any old header
-                del(self.headers)
-            except AttributeError:
+                # use lower case for key to delete. Probably
+                # a bug.
+                del(self.dummy_client.request.headers['etag'])
+            except (AttributeError,KeyError):
                 pass
 
             form = cgi.FieldStorage()
@@ -369,21 +367,21 @@
 
             if mode == 'header':
                 print("Mode = %s"%mode)
-                self.headers = {'etag': etag}
+                self.dummy_client.request.headers['Etag'] = etag
             elif mode == 'etag':
                 print("Mode = %s"%mode)
                 form.list.append(cgi.MiniFieldStorage('@etag', etag))
             elif mode == 'both':
                 print("Mode = %s"%mode)
-                self.headers = {'etag': etag}
+                self.dummy_client.request.headers['Etag'] = etag
                 form.list.append(cgi.MiniFieldStorage('@etag', etag))
             elif mode == 'brokenheader':
                 print("Mode = %s"%mode)
-                self.headers = {'etag': 'bad'}
+                self.dummy_client.request.headers['Etag'] = 'bad'
                 form.list.append(cgi.MiniFieldStorage('@etag', etag))
             elif mode == 'brokenetag':
                 print("Mode = %s"%mode)
-                self.headers = {'etag': etag}
+                self.dummy_client.request.headers['Etag'] = etag
                 form.list.append(cgi.MiniFieldStorage('@etag', 'bad'))
             elif mode == 'none':
                 print( "Mode = %s"%mode)
@@ -398,6 +396,10 @@
             else:
                 self.assertEqual(self.dummy_client.response_code, 412)
 
+    def make_file(self):
+        import tempfile
+        return tempfile.TemporaryFile("wb+")
+
     def testDispatch(self):
         """
         run changes through rest dispatch(). This also tests
@@ -405,11 +407,20 @@
         code that changes json payload into something we can
         process.
         """
-        # Set joe's 'realname' using json data.
+
+        # Override the make_file so it is always set to binary
+        # read mode. This is needed so we can send a json 
+        # body.
+        saved_make_file = cgi.FieldStorage.make_file
+        cgi.FieldStorage.make_file = self.make_file
+
+
+        # TEST #1
+        # PUT: joe's 'realname' using json data.
         # simulate: /rest/data/user/<id>/realname
         # use etag in header
         etag = calculate_etag(self.db.user.getnode(self.joeid))
-        body=u'{ "data": "Joe Doe 1" }'
+        body='{ "data": "Joe Doe 1" }'
         env = { "CONTENT_TYPE": "application/json",
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "PUT"
@@ -418,31 +429,31 @@
                  "content-type": env['CONTENT_TYPE'],
                  "etag": etag
         }
-        self.headers=headers
         # we need to generate a FieldStorage the looks like
         #  FieldStorage(None, None, 'string') rather than
         #  FieldStorage(None, None, [])
-        body_file=StringIO(body)  # FieldStorage needs a file
+        body_file=BytesIO(s2b(body))  # FieldStorage needs a file
+        cgi.FieldStorage.make_file = self.make_file
         form = cgi.FieldStorage(body_file,
                                 headers=headers,
                                 environ=env)
-        self.server.client.request.headers.getheader=self.get_header
+        self.server.client.request.headers.update(headers) # set headers
         results = self.server.dispatch('PUT',
                             "/rest/data/user/%s/realname"%self.joeid,
                             form)
-
         self.assertEqual(self.server.client.response_code, 200)
         results = self.server.get_element('user', self.joeid, self.empty_form)
         self.assertEqual(self.dummy_client.response_code, 200)
         self.assertEqual(results['data']['attributes']['realname'],
                          'Joe Doe 1')
-        del(self.headers)
+        self.server.client.request.headers.clear() # set headers
 
+        # TEST #2
         # Set joe's 'realname' using json data.
         # simulate: /rest/data/user/<id>/realname
         # use etag in payload
         etag = calculate_etag(self.db.user.getnode(self.joeid))
-        body=u'{ "@etag": "%s", "data": "Joe Doe 2" }'%etag
+        body='{ "@etag": "%s", "data": "Joe Doe 2" }'%etag
         env = { "CONTENT_TYPE": "application/json",
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "PUT"
@@ -450,12 +461,11 @@
         headers={"accept": "application/json",
                  "content-type": env['CONTENT_TYPE']
         }
-        self.headers=headers
-        body_file=StringIO(body)  # FieldStorage needs a file
+        body_file=BytesIO(s2b(body))  # FieldStorage needs a file
         form = cgi.FieldStorage(body_file,
                                 headers=headers,
                                 environ=env)
-        self.server.client.request.headers.getheader=self.get_header
+        self.server.client.request.headers.update(headers) # set headers
         results = self.server.dispatch('PUT',
                             "/rest/data/user/%s/realname"%self.joeid,
                             form)
@@ -465,15 +475,13 @@
         self.assertEqual(self.dummy_client.response_code, 200)
         self.assertEqual(results['data']['attributes']['realname'],
                          'Joe Doe 2')
-        del(self.headers)
+        self.server.client.request.headers.clear()
 
+        # TEST #3
         # change Joe's realname via a normal web form
-        # This generates a FieldStorage that looks like:
-        #  FieldStorage(None, None, [])
         # use etag from header
         #
-        # also use a GET on the uri via the dispatch to get
-        # the results from the db.
+        # Also do a GET using dispatch to get the results from the db.
         etag = calculate_etag(self.db.user.getnode(self.joeid))
         headers={"etag": etag,
                  "accept": "application/json",
@@ -482,8 +490,7 @@
         form.list = [
             cgi.MiniFieldStorage('data', 'Joe Doe'),
         ]
-        self.headers = headers
-        self.server.client.request.headers.getheader = self.get_header
+        self.server.client.request.headers.update(headers) # set headers
         results = self.server.dispatch('PUT',
                             "/rest/data/user/%s/realname"%self.joeid,
                             form)
@@ -492,25 +499,27 @@
                             "/rest/data/user/%s/realname"%self.joeid,
                                        self.empty_form)
         self.assertEqual(self.dummy_client.response_code, 200)
-        json_dict = json.loads(results)
 
+        json_dict = json.loads(b2s(results))
         self.assertEqual(json_dict['data']['data'], 'Joe Doe')
         self.assertEqual(json_dict['data']['link'],
                          "http://tracker.example/cgi-bin/"
                          "roundup.cgi/bugs/rest/data/user/3/realname") 
-        self.assertEqual(json_dict['data']['type'], "<type 'str'>")
+        self.assertIn(json_dict['data']['type'], ("<class 'str'>",
+                                                  "<type 'str'>"))
         self.assertEqual(json_dict['data']["id"], "3")
-        del(self.headers)
+        self.server.client.request.headers.clear()
 
 
-        # PATCH joe's email address with json
+        # TEST #4
+        # PATCH: joe's email address with json
         # save address so we can use it later
         stored_results = self.server.get_element('user', self.joeid,
                                                  self.empty_form)
         self.assertEqual(self.dummy_client.response_code, 200)
 
         etag = calculate_etag(self.db.user.getnode(self.joeid))
-        body=u'{ "address": "demo2@example.com", "@etag": "%s"}'%etag
+        body='{ "address": "demo2@example.com", "@etag": "%s"}'%etag
         env = { "CONTENT_TYPE": "application/json",
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "PATCH"
@@ -518,12 +527,11 @@
         headers={"accept": "application/json",
                  "content-type": env['CONTENT_TYPE']
         }
-        self.headers=headers
-        body_file=StringIO(body)  # FieldStorage needs a file
+        body_file=BytesIO(s2b(body))  # FieldStorage needs a file
         form = cgi.FieldStorage(body_file,
                                 headers=headers,
                                 environ=env)
-        self.server.client.request.headers.getheader=self.get_header
+        self.server.client.request.headers.update(headers) # set headers
         results = self.server.dispatch('PATCH',
                             "/rest/data/user/%s"%self.joeid,
                             form)
@@ -533,18 +541,16 @@
         self.assertEqual(self.dummy_client.response_code, 200)
         self.assertEqual(results['data']['attributes']['address'],
                          'demo2@example.com')
-
-        # and set it back
+        # and set it back reusing env and headers from last test
         etag = calculate_etag(self.db.user.getnode(self.joeid))
-        body=u'{ "address": "%s", "@etag": "%s"}'%(
+        body='{ "address": "%s", "@etag": "%s"}'%(
             stored_results['data']['attributes']['address'],
             etag)
         # reuse env and headers from prior test.
-        body_file=StringIO(body)  # FieldStorage needs a file
+        body_file=BytesIO(s2b(body))  # FieldStorage needs a file
         form = cgi.FieldStorage(body_file,
                                 headers=headers,
                                 environ=env)
-        self.server.client.request.headers.getheader=self.get_header
         results = self.server.dispatch('PATCH',
                             "/rest/data/user/%s"%self.joeid,
                             form)
@@ -554,11 +560,17 @@
         self.assertEqual(self.dummy_client.response_code, 200)
         self.assertEqual(results['data']['attributes']['address'],
                          'random@home.org')
-        del(self.headers)
+        self.server.client.request.headers.clear()
+
 
-        # POST to create new issue
-        body=u'{ "title": "foo bar", "priority": "critical" }'
-
+        # TEST #5
+        # POST: create new issue
+        # no etag needed
+        # FIXME at some point we probably want to implement
+        # Post Once Only, so we need to add a Post Once Exactly
+        # test and a resubmit as well.
+        etag = "not needed"
+        body='{ "title": "foo bar", "priority": "critical" }'
         env = { "CONTENT_TYPE": "application/json",
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "POST"
@@ -566,18 +578,16 @@
         headers={"accept": "application/json",
                  "content-type": env['CONTENT_TYPE']
         }
-        self.headers=headers
-        body_file=StringIO(body)  # FieldStorage needs a file
+        body_file=BytesIO(s2b(body))  # FieldStorage needs a file
         form = cgi.FieldStorage(body_file,
                                 headers=headers,
                                 environ=env)
-        self.server.client.request.headers.getheader=self.get_header
+        self.server.client.request.headers.update(headers) # set headers
         results = self.server.dispatch('POST',
                             "/rest/data/issue",
                             form)
-
         self.assertEqual(self.server.client.response_code, 201)
-        json_dict = json.loads(results)
+        json_dict = json.loads(b2s(results))
         issue_id=json_dict['data']['id']
         results = self.server.get_element('issue',
                             str(issue_id), # must be a string not unicode
@@ -585,7 +595,10 @@
         self.assertEqual(self.dummy_client.response_code, 200)
         self.assertEqual(results['data']['attributes']['title'],
                          'foo bar')
-        del(self.headers)
+        self.server.client.request.headers.clear()
+
+        # reset the make_file method in the class
+        cgi.FieldStorage.make_file = saved_make_file
 
     def testPut(self):
         """
@@ -615,7 +628,7 @@
             cgi.MiniFieldStorage('data', 'Joe Doe Doe'),
         ]
 
-        self.headers = {'etag': etag } # use etag in header
+        self.dummy_client.request.headers['ETag'] = etag # use etag in header
         results = self.server.put_attribute(
             'user', self.joeid, 'realname', form
         )
@@ -625,7 +638,7 @@
         )
         self.assertEqual(self.dummy_client.response_code, 200)
         self.assertEqual(results['data']['data'], 'Joe Doe Doe')
-        del(self.headers)
+        del(self.dummy_client.request.headers['etag'])
 
         # Reset joe's 'realname'. etag in body
         form = cgi.FieldStorage()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test/test_CaseInsensitiveDict.py	Sun Mar 17 19:28:26 2019 -0400
@@ -0,0 +1,63 @@
+# test_CaseInsensitiveDict.py
+# By miloskot at:
+#  https://stackoverflow.com/questions/2082152/case-insensitive-dictionary
+
+import json
+import unittest
+from .caseinsensitivedict import *
+
+class Key(unittest.TestCase):
+    def setUp(self):
+        self.Key = CaseInsensitiveDict.Key
+        self.lower = self.Key('a')
+        self.upper = self.Key('A')
+
+    def test_eq(self):
+        self.assertEqual(self.lower, self.upper)
+
+    def test_hash(self):
+        self.assertEqual(hash(self.lower), hash(self.upper))
+
+    def test_str(self):
+        self.assertEqual(str(self.lower), 'a')
+        self.assertEqual(str(self.upper), 'A')
+
+class Dict(unittest.TestCase):
+    def setUp(self):
+        self.Dict = CaseInsensitiveDict
+        self.d1 = self.Dict()
+        self.d2 = self.Dict()
+        self.d1['a'] = 1
+        self.d1['B'] = 2
+        self.d2['A'] = 1
+        self.d2['b'] = 2
+
+    def test_contains(self):
+        self.assertIn('B', self.d1)
+        d = self.Dict({'a':1, 'B':2})
+        self.assertIn('b', d)
+
+    def test_init(self):
+        d = self.Dict()
+        self.assertFalse(d)
+        d = self.Dict({'a':1, 'B':2})
+        self.assertTrue(d)
+
+    def test_items(self):
+        self.assertDictEqual(self.d1, self.d2)
+        self.assertEqual(
+            [v for v in self.d1.items()],
+            [v for v in self.d2.items()])
+
+    def test_json_dumps(self):
+        s = json.dumps(self.d1)
+        self.assertIn('a', s)
+        self.assertIn('B', s)
+
+    def test_keys(self):
+        self.assertEqual(self.d1.keys(), self.d2.keys())
+
+    def test_values(self):
+        self.assertEqual(
+            [v for v in self.d1.values()],
+            [v for v in self.d2.values()])

Roundup Issue Tracker: http://roundup-tracker.org/