Repository: urllib3/urllib3

Issue:
Move GAE tox env to use the pytest runner
Context: https://github.com/shazow/urllib3/pull/1187#issuecomment-304813261
Related: https://github.com/shazow/urllib3/issues/1160


Discussion:

Resolving patch:
diff --git a/urllib3/connectionpool.py b/urllib3/connectionpool.py
--- a/urllib3/connectionpool.py
+++ b/urllib3/connectionpool.py
@@ -396,7 +396,7 @@ def _make_request(self, conn, method, url, timeout=_Default, chunked=False,
 
         try:
             assert_header_parsing(httplib_response.msg)
-        except HeaderParsingError as hpe:  # Platform-specific: Python 3
+        except (HeaderParsingError, TypeError) as hpe:  # Platform-specific: Python 3
             log.warning(
                 'Failed to parse headers (url=%s): %s',
                 self._absolute_url(url), hpe, exc_info=True)


Test patch:
diff --git a/test/appengine/__init__.py b/test/appengine/__init__.py
--- a/test/appengine/__init__.py
+++ b/test/appengine/__init__.py
@@ -1,71 +0,0 @@
-import os
-import sys
-import unittest
-from nose.plugins.skip import SkipTest
-
-
-def activate_sandbox():
-    """
-    Enables parts of the GAE sandbox that are relevant.
-
-    Inserts the stub module import hook which causes the usage of appengine-specific
-    httplib, httplib2, socket, etc.
-    """
-    from google.appengine.tools.devappserver2.python import sandbox
-
-    for name in list(sys.modules):
-        if name in sandbox.dist27.MODULE_OVERRIDES:
-            del sys.modules[name]
-    sys.meta_path.insert(0, sandbox.StubModuleImportHook())
-    sys.path_importer_cache = {}
-
-
-def deactivate_sandbox():
-    from google.appengine.tools.devappserver2.python import sandbox
-
-    sys.meta_path = [
-        x for x in sys.meta_path if not isinstance(x, sandbox.StubModuleImportHook)]
-    sys.path_importer_cache = {}
-
-    # Delete any instances of sandboxed modules.
-    for name in list(sys.modules):
-        if name in sandbox.dist27.MODULE_OVERRIDES:
-            del sys.modules[name]
-
-
-class AppEngineSandboxTest(unittest.TestCase):
-
-    @classmethod
-    def setUpClass(cls):
-
-        if sys.version_info[:2] != (2, 7):
-            raise SkipTest("App Engine only tests on py2.7")
-
-        if 'APPLICATION_ID' not in os.environ:
-            raise SkipTest("NoseGAE plugin not used.")
-
-        try:
-            activate_sandbox()
-        except ImportError:
-            raise SkipTest("App Engine SDK not available.")
-
-    @classmethod
-    def tearDownClass(self):
-        try:
-            deactivate_sandbox()
-        except ImportError:
-            pass
-
-
-class MockResponse(object):
-    def __init__(self, content, status_code, content_was_truncated, final_url, headers):
-        import httplib
-        from StringIO import StringIO
-
-        self.content = content
-        self.status_code = status_code
-        self.content_was_truncated = content_was_truncated
-        self.final_url = final_url
-        self.header_msg = httplib.HTTPMessage(StringIO(''.join(
-            ["%s: %s\n" % (k, v) for k, v in headers.iteritems()] + ["\n"])))
-        self.headers = self.header_msg.items()
diff --git a/test/appengine/app.yaml b/test/appengine/app.yaml
deleted file mode 100644
--- a/test/appengine/app.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-# dummy app.yaml for nosegae
-
-api_version: 1
-runtime: python27
-threadsafe: true
-
-handlers:
-- url: /
-  static_files: README.md
-  upload: README.md
-  mime_type: text/plain
diff --git a/test/appengine/conftest.py b/test/appengine/conftest.py
new file mode 100644
--- /dev/null
+++ b/test/appengine/conftest.py
@@ -0,0 +1,72 @@
+# Copyright 2015 Google 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 os
+import sys
+
+# Import py.test hooks and fixtures for App Engine
+from gcp_devrel.testing.appengine import (
+    pytest_configure,
+    pytest_runtest_call,
+    testbed
+)
+import pytest
+import six
+
+
+__all__ = [
+    'pytest_configure',
+    'pytest_runtest_call',
+    'pytest_ignore_collect',
+    'testbed',
+    'sandbox',
+]
+
+
+@pytest.fixture
+def sandbox(testbed):
+    """
+    Enables parts of the GAE sandbox that are relevant.
+    Inserts the stub module import hook which causes the usage of
+    appengine-specific httplib, httplib2, socket, etc.
+    """
+    from google.appengine.tools.devappserver2.python import sandbox
+
+    for name in list(sys.modules):
+        if name in sandbox.dist27.MODULE_OVERRIDES:
+            del sys.modules[name]
+    sys.meta_path.insert(0, sandbox.StubModuleImportHook())
+    sys.path_importer_cache = {}
+
+    yield testbed
+
+    sys.meta_path = [
+        x for x in sys.meta_path
+        if not isinstance(x, sandbox.StubModuleImportHook)]
+    sys.path_importer_cache = {}
+
+    # Delete any instances of sandboxed modules.
+    for name in list(sys.modules):
+        if name in sandbox.dist27.MODULE_OVERRIDES:
+            del sys.modules[name]
+
+
+def pytest_ignore_collect(path, config):
+    """Skip App Engine tests in python 3 or if no SDK is available."""
+    if 'appengine' in str(path):
+        if six.PY3:
+            return True
+        if not os.environ.get('GAE_SDK_PATH'):
+            return True
+    return False
diff --git a/test/appengine/nose.cfg b/test/appengine/nose.cfg
deleted file mode 100644
--- a/test/appengine/nose.cfg
+++ /dev/null
@@ -1,4 +0,0 @@
-[nosetests]
-cover-min-percentage=0
-with-gae=1
-gae-application=test/appengine/app.yaml
diff --git a/test/appengine/requirements.txt b/test/appengine/requirements.txt
deleted file mode 100644
--- a/test/appengine/requirements.txt
+++ /dev/null
@@ -1 +0,0 @@
-NoseGAE==0.5.10
diff --git a/test/contrib/test_gae_manager.py b/test/appengine/test_gae_manager.py
similarity index 69%
rename from test/contrib/test_gae_manager.py
rename to test/appengine/test_gae_manager.py
--- a/test/contrib/test_gae_manager.py
+++ b/test/appengine/test_gae_manager.py
@@ -1,29 +1,12 @@
-import unittest
+import dummyserver.testcase
+import pytest
 
-from dummyserver.testcase import HTTPSDummyServerTestCase
-from nose.plugins.skip import SkipTest
+from urllib3.contrib import appengine
+import urllib3.exceptions
+import urllib3.util.url
+import urllib3.util.retry
 
-try:
-    from google.appengine.api import urlfetch
-    (urlfetch)
-except ImportError:
-    raise SkipTest("App Engine SDK not available.")
-
-from urllib3.contrib.appengine import AppEngineManager, AppEnginePlatformError
-from urllib3.exceptions import (
-    TimeoutError,
-    ProtocolError,
-    SSLError)
-from urllib3.util.url import Url
-from urllib3.util.retry import Retry, RequestHistory
-
-from test.with_dummyserver.test_connectionpool import (
-    TestConnectionPool, TestRetry, TestRetryAfter)
-
-
-# Prevent nose from running these test.
-TestConnectionPool.__test__ = False
-TestRetry.__test__ = False
+from test.with_dummyserver import test_connectionpool
 
 
 # This class is used so we can re-use the tests from the connection pool.
@@ -44,7 +27,7 @@ def urlopen(self, method, url, *args, **kwargs):
         return self.manager.urlopen(method, url, *args, **kwargs)
 
     def _absolute_url(self, path):
-        return Url(
+        return urllib3.util.url.Url(
             scheme=self.scheme,
             host=self.host,
             port=self.port,
@@ -54,14 +37,11 @@ def _absolute_url(self, path):
 # Note that this doesn't run in the sandbox, it only runs with the URLFetch
 # API stub enabled. There's no need to enable the sandbox as we know for a fact
 # that URLFetch is used by the connection manager.
-class TestGAEConnectionManager(TestConnectionPool):
-    __test__ = True
-
-    # Magic class variable that tells NoseGAE to enable the URLFetch stub.
-    nosegae_urlfetch = True
+@pytest.mark.usefixtures('testbed')
+class TestGAEConnectionManager(test_connectionpool.TestConnectionPool):
 
     def setUp(self):
-        self.manager = AppEngineManager()
+        self.manager = appengine.AppEngineManager()
         self.pool = MockPool(self.host, self.port, self.manager)
 
     # Tests specific to AppEngineManager
@@ -69,7 +49,7 @@ def setUp(self):
     def test_exceptions(self):
         # DeadlineExceededError -> TimeoutError
         self.assertRaises(
-            TimeoutError,
+            urllib3.exceptions.TimeoutError,
             self.pool.request,
             'GET',
             '/sleep?seconds=0.005',
@@ -77,21 +57,21 @@ def test_exceptions(self):
 
         # InvalidURLError -> ProtocolError
         self.assertRaises(
-            ProtocolError,
+            urllib3.exceptions.ProtocolError,
             self.manager.request,
             'GET',
             'ftp://invalid/url')
 
         # DownloadError -> ProtocolError
         self.assertRaises(
-            ProtocolError,
+            urllib3.exceptions.ProtocolError,
             self.manager.request,
             'GET',
             'http://0.0.0.0')
 
         # ResponseTooLargeError -> AppEnginePlatformError
         self.assertRaises(
-            AppEnginePlatformError,
+            appengine.AppEnginePlatformError,
             self.pool.request,
             'GET',
             '/nbytes?length=33554433')  # One byte over 32 megabtyes.
@@ -100,7 +80,7 @@ def test_exceptions(self):
         # which maps to a AppEnginePlatformError.
         body = b'1' * 10485761  # One byte over 10 megabytes.
         self.assertRaises(
-            AppEnginePlatformError,
+            appengine.AppEnginePlatformError,
             self.manager.request,
             'POST',
             '/',
@@ -139,11 +119,12 @@ def test_exceptions(self):
     test_dns_error = None
 
 
-class TestGAEConnectionManagerWithSSL(HTTPSDummyServerTestCase):
-    nosegae_urlfetch = True
+@pytest.mark.usefixtures('testbed')
+class TestGAEConnectionManagerWithSSL(
+        dummyserver.testcase.HTTPSDummyServerTestCase):
 
     def setUp(self):
-        self.manager = AppEngineManager()
+        self.manager = appengine.AppEngineManager()
         self.pool = MockPool(self.host, self.port, self.manager, 'https')
 
     def test_exceptions(self):
@@ -151,25 +132,22 @@ def test_exceptions(self):
         # SSLError is raised with dummyserver because URLFetch doesn't allow
         # self-signed certs.
         self.assertRaises(
-            SSLError,
+            urllib3.exceptions.SSLError,
             self.pool.request,
             'GET',
             '/')
 
 
-class TestGAERetry(TestRetry):
-    __test__ = True
-
-    # Magic class variable that tells NoseGAE to enable the URLFetch stub.
-    nosegae_urlfetch = True
+@pytest.mark.usefixtures('testbed')
+class TestGAERetry(test_connectionpool.TestRetry):
 
     def setUp(self):
-        self.manager = AppEngineManager()
+        self.manager = appengine.AppEngineManager()
         self.pool = MockPool(self.host, self.port, self.manager)
 
     def test_default_method_whitelist_retried(self):
         """ urllib3 should retry methods in the default method whitelist """
-        retry = Retry(total=1, status_forcelist=[418])
+        retry = urllib3.util.retry.Retry(total=1, status_forcelist=[418])
         # Use HEAD instead of OPTIONS, as URLFetch doesn't support OPTIONS
         resp = self.pool.request(
             'HEAD', '/successful_retry',
@@ -179,16 +157,18 @@ def test_default_method_whitelist_retried(self):
 
     def test_retry_return_in_response(self):
         headers = {'test-name': 'test_retry_return_in_response'}
-        retry = Retry(total=2, status_forcelist=[418])
+        retry = urllib3.util.retry.Retry(total=2, status_forcelist=[418])
         resp = self.pool.request('GET', '/successful_retry',
                                  headers=headers, retries=retry)
         self.assertEqual(resp.status, 200)
         self.assertEqual(resp.retries.total, 1)
         # URLFetch use absolute urls.
-        self.assertEqual(resp.retries.history,
-                         (RequestHistory('GET',
-                                         self.pool._absolute_url('/successful_retry'),
-                                         None, 418, None),))
+        self.assertEqual(
+            resp.retries.history,
+            (urllib3.util.retry.RequestHistory(
+                'GET',
+                self.pool._absolute_url('/successful_retry'),
+                None, 418, None),))
 
     # test_max_retry = None
     # test_disabled_retry = None
@@ -197,17 +177,10 @@ def test_retry_return_in_response(self):
     test_multi_redirect_history = None
 
 
-class TestGAERetryAfter(TestRetryAfter):
-    __test__ = True
-
-    # Magic class variable that tells NoseGAE to enable the URLFetch stub.
-    nosegae_urlfetch = True
+@pytest.mark.usefixtures('testbed')
+class TestGAERetryAfter(test_connectionpool.TestRetryAfter):
 
     def setUp(self):
         # Disable urlfetch which doesn't respect Retry-After header.
-        self.manager = AppEngineManager(urlfetch_retries=False)
+        self.manager = appengine.AppEngineManager(urlfetch_retries=False)
         self.pool = MockPool(self.host, self.port, self.manager)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/test/appengine/test_urlfetch.py b/test/appengine/test_urlfetch.py
--- a/test/appengine/test_urlfetch.py
+++ b/test/appengine/test_urlfetch.py
@@ -1,49 +1,73 @@
-from . import AppEngineSandboxTest, MockResponse
+"""These tests ensure that when running in App Engine standard with the
+App Engine sandbox enabled that urllib3 appropriately uses the App
+Engine-patched version of httplib to make requests."""
+
+import httplib
+import StringIO
+import unittest
 
 from mock import patch
-from nose.plugins.skip import SkipTest
+import pytest
+
 from ..test_no_ssl import TestWithoutSSL
 
 
-class TestHTTP(AppEngineSandboxTest, TestWithoutSSL):
-    nosegae_urlfetch = True
+class MockResponse(object):
+    def __init__(self, content, status_code, content_was_truncated, final_url,
+                 headers):
+
+        self.content = content
+        self.status_code = status_code
+        self.content_was_truncated = content_was_truncated
+        self.final_url = final_url
+        self.header_msg = httplib.HTTPMessage(StringIO.StringIO(''.join(
+            ["%s: %s\n" % (k, v) for k, v in headers.iteritems()] + ["\n"])))
+        self.headers = headers
+
 
+@pytest.mark.usefixtures('sandbox')
+class TestHTTP(TestWithoutSSL):
     def test_urlfetch_called_with_http(self):
-        """
-        Check that URLFetch is used to fetch non-https resources
-        """
+        """Check that URLFetch is used to fetch non-https resources."""
         resp = MockResponse(
             'OK',
             200,
             False,
             'http://www.google.com',
             {'content-type': 'text/plain'})
-        with patch('google.appengine.api.urlfetch.fetch', return_value=resp) as fetchmock:
+        fetch_patch = patch(
+            'google.appengine.api.urlfetch.fetch', return_value=resp
+        )
+        with fetch_patch as fetch_mock:
             import urllib3
             pool = urllib3.HTTPConnectionPool('www.google.com', '80')
             r = pool.request('GET', '/')
             self.assertEqual(r.status, 200, r.data)
-            self.assertEqual(fetchmock.call_count, 1)
-
+            self.assertEqual(fetch_mock.call_count, 1)
 
-class TestHTTPS(AppEngineSandboxTest):
-    nosegae_urlfetch = True
 
+@pytest.mark.usefixtures('sandbox')
+class TestHTTPS(unittest.TestCase):
+    @pytest.mark.xfail(
+        reason='This is not yet supported by urlfetch, presence of the ssl '
+               'module will bypass urlfetch.')
     def test_urlfetch_called_with_https(self):
         """
         Check that URLFetch is used when fetching https resources
         """
-        raise SkipTest()  # Skipped for now because it fails.
         resp = MockResponse(
             'OK',
             200,
             False,
             'https://www.google.com',
             {'content-type': 'text/plain'})
-        with patch('google.appengine.api.urlfetch.fetch', return_value=resp) as fetchmock:
+        fetch_patch = patch(
+            'google.appengine.api.urlfetch.fetch', return_value=resp
+        )
+        with fetch_patch as fetch_mock:
             import urllib3
             pool = urllib3.HTTPSConnectionPool('www.google.com', '443')
             pool.ConnectionCls = urllib3.connection.UnverifiedHTTPSConnection
             r = pool.request('GET', '/')
             self.assertEqual(r.status, 200, r.data)
-            self.assertEqual(fetchmock.call_count, 1)
+            self.assertEqual(fetch_mock.call_count, 1)