Repository: twisted/treq

Issue:
public APIs should not swallow arbitrary invalid keyword arguments
This needs to be done in three steps:

- [x] emit a deprecation warning when emitting unused keyword arguments
- [x] do a release with the deprecation warning
- [x] remove the `**kwargs` entirely

#251 was both the initial report of this as well as a proposed implementation, but its author lost interest.

Discussion:

Patch:
diff --git a/src/treq/client.py b/src/treq/client.py
index 7924d11..48fdc7f 100644
--- a/src/treq/client.py
+++ b/src/treq/client.py
@@ -143,12 +143,29 @@ class HTTPClient:
         kwargs.setdefault('_stacklevel', 3)
         return self.request('DELETE', url, **kwargs)
 
-    def request(self, method, url, **kwargs):
+    def request(
+        self,
+        method,
+        url,
+        *,
+        params=None,
+        headers=None,
+        data=None,
+        files=None,
+        json=_NOTHING,
+        auth=None,
+        cookies=None,
+        allow_redirects=True,
+        browser_like_redirects=False,
+        unbuffered=False,
+        reactor=None,
+        timeout=None,
+        _stacklevel=2,
+    ):
         """
         See :func:`treq.request()`.
         """
         method = method.encode('ascii').upper()
-        stacklevel = kwargs.pop('_stacklevel', 2)
 
         if isinstance(url, DecodedURL):
             parsed_url = url.encoded_url
@@ -163,7 +180,6 @@ class HTTPClient:
 
         # Join parameters provided in the URL
         # and the ones passed as argument.
-        params = kwargs.pop('params', None)
         if params:
             parsed_url = parsed_url.replace(
                 query=parsed_url.query + tuple(_coerced_query_params(params))
@@ -171,27 +187,20 @@ class HTTPClient:
 
         url = parsed_url.to_uri().to_text().encode('ascii')
 
-        headers = self._request_headers(kwargs.pop('headers', None), stacklevel + 1)
+        headers = self._request_headers(headers, _stacklevel + 1)
 
-        bodyProducer, contentType = self._request_body(
-            data=kwargs.pop('data', None),
-            files=kwargs.pop('files', None),
-            json=kwargs.pop('json', _NOTHING),
-            stacklevel=stacklevel + 1,
-        )
+        bodyProducer, contentType = self._request_body(data, files, json,
+                                                       stacklevel=_stacklevel + 1)
         if contentType is not None:
             headers.setRawHeaders(b'Content-Type', [contentType])
 
-        cookies = kwargs.pop('cookies', {})
-
         if not isinstance(cookies, CookieJar):
             cookies = cookiejar_from_dict(cookies)
 
         cookies = merge_cookies(self._cookiejar, cookies)
         wrapped_agent = CookieAgent(self._agent, cookies)
 
-        browser_like_redirects = kwargs.pop('browser_like_redirects', False)
-        if kwargs.pop('allow_redirects', True):
+        if allow_redirects:
             if browser_like_redirects:
                 wrapped_agent = BrowserLikeRedirectAgent(wrapped_agent)
             else:
@@ -200,7 +209,6 @@ class HTTPClient:
         wrapped_agent = ContentDecoderAgent(wrapped_agent,
                                             [(b'gzip', GzipDecoder)])
 
-        auth = kwargs.pop('auth', None)
         if auth:
             wrapped_agent = add_auth(wrapped_agent, auth)
 
@@ -208,10 +216,8 @@ class HTTPClient:
             method, url, headers=headers,
             bodyProducer=bodyProducer)
 
-        reactor = kwargs.pop('reactor', None)
         if reactor is None:
             from twisted.internet import reactor
-        timeout = kwargs.pop('timeout', None)
         if timeout:
             delayedCall = reactor.callLater(timeout, d.cancel)
 
@@ -222,20 +228,9 @@ class HTTPClient:
 
             d.addBoth(gotResult)
 
-        if not kwargs.pop('unbuffered', False):
+        if not unbuffered:
             d.addCallback(_BufferedResponse)
 
-        if kwargs:
-            warnings.warn(
-                (
-                    "Got unexpected keyword argument: {}."
-                    " treq will ignore this argument,"
-                    " but will raise TypeError in the next treq release."
-                ).format(", ".join(repr(k) for k in kwargs)),
-                DeprecationWarning,
-                stacklevel=stacklevel,
-            )
-
         return d.addCallback(_Response, cookies)
 
     def _request_headers(self, headers, stacklevel):


Test patch:
diff --git a/src/treq/test/test_api.py b/src/treq/test/test_api.py
index f7f9e09..2d9a1a1 100644
--- a/src/treq/test/test_api.py
+++ b/src/treq/test/test_api.py
@@ -101,25 +101,15 @@ class TreqAPITests(TestCase):
         This test verifies that stacklevel is set appropriately when issuing
         the warning.
         """
-        self.failureResultOf(
+        with self.assertRaises(TypeError) as c:
             treq.request(
                 "GET",
                 "https://foo.bar",
                 invalid=True,
                 pool=SyntacticAbominationHTTPConnectionPool(),
             )
-        )
 
-        [w] = self.flushWarnings([self.test_request_invalid_param])
-        self.assertEqual(DeprecationWarning, w["category"])
-        self.assertEqual(
-            (
-                "Got unexpected keyword argument: 'invalid'."
-                " treq will ignore this argument,"
-                " but will raise TypeError in the next treq release."
-            ),
-            w["message"],
-        )
+        self.assertIn("invalid", str(c.exception))
 
     def test_post_json_with_data(self):
         """
diff --git a/src/treq/test/test_client.py b/src/treq/test/test_client.py
index ee9f3fd..52897ce 100644
--- a/src/treq/test/test_client.py
+++ b/src/treq/test/test_client.py
@@ -655,19 +655,15 @@ class HTTPClientTests(TestCase):
 
     def test_request_invalid_param(self):
         """
-        `HTTPClient.request()` warns that invalid parameters are ignored and
-        that this is deprecated.
+        `HTTPClient.request()` rejects invalid keyword parameters with
+        `TypeError`.
         """
-        self.client.request('GET', b'http://example.com', invalid=True)
-
-        [w] = self.flushWarnings([self.test_request_invalid_param])
-        self.assertEqual(
-            (
-                "Got unexpected keyword argument: 'invalid'."
-                " treq will ignore this argument,"
-                " but will raise TypeError in the next treq release."
-            ),
-            w['message'],
+        self.assertRaises(
+            TypeError,
+            self.client.request,
+            "GET",
+            b"http://example.com",
+            invalid=True,
         )
 
     @with_clock
diff --git a/src/treq/test/test_multipart.py b/src/treq/test/test_multipart.py
index fd170e3..d193037 100644
--- a/src/treq/test/test_multipart.py
+++ b/src/treq/test/test_multipart.py
@@ -78,20 +78,26 @@ class MultiPartProducerTestCase(unittest.TestCase):
         passed as a parameter without either a C{seek} or C{tell} method,
         its C{length} attribute is set to C{UNKNOWN_LENGTH}.
         """
-        class HasSeek:
+        class CantTell:
             def seek(self, offset, whence):
-                pass
+                """
+                A C{seek} method that is never called because there is no
+                matching C{tell} method.
+                """
 
-        class HasTell:
+        class CantSeek:
             def tell(self):
-                pass
+                """
+                A C{tell} method that is never called because there is no
+                matching C{seek} method.
+                """
 
         producer = MultiPartProducer(
-            {"f": ("name", None, FileBodyProducer(HasSeek()))})
+            {"f": ("name", None, FileBodyProducer(CantTell()))})
         self.assertEqual(UNKNOWN_LENGTH, producer.length)
 
         producer = MultiPartProducer(
-            {"f": ("name", None, FileBodyProducer(HasTell()))})
+            {"f": ("name", None, FileBodyProducer(CantSeek()))})
         self.assertEqual(UNKNOWN_LENGTH, producer.length)
 
     def test_knownLengthOnFile(self):