From 48e151d4c62b4f4a9f906f75182e60b1cb8c40e1 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Mon, 25 May 2026 04:04:58 -0500 Subject: [PATCH] fix: check callable before tuple in prepare_auth When auth is both callable and a 2-element tuple subclass (e.g. a namedtuple-based AuthBase subclass), the previous code took the isinstance(auth, tuple) branch first and silently constructed HTTPBasicAuth(*auth) instead of invoking the object's __call__. This meant the custom auth handler was never called and its fields were extracted as Basic Auth credentials. Flip the check order so callable(auth) is tested first. Plain (username, password) tuples are unaffected: HTTPBasicAuth is itself callable, but a bare tuple is not, so it still reaches the isinstance branch correctly. Closes the existing TODO comment: "can be fixed by flipping the conditionals" --- src/requests/models.py | 5 +++-- tests/test_requests.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/requests/models.py b/src/requests/models.py index 59b5615960..9f25d8c939 100644 --- a/src/requests/models.py +++ b/src/requests/models.py @@ -678,11 +678,12 @@ def prepare_auth( auth = url_auth if any(url_auth) else None if auth: - if isinstance(auth, tuple) and len(auth) == 2: # type: ignore[arg-type] # pyright widens tuple from Callable in AuthType + if callable(auth): + auth_handler = cast("Callable[..., PreparedRequest]", auth) + elif isinstance(auth, tuple) and len(auth) == 2: # type: ignore[arg-type] # pyright widens tuple from Callable in AuthType # special-case basic HTTP auth auth_handler = HTTPBasicAuth(*auth) # type: ignore[arg-type] # pyright widens tuple from Callable in AuthType else: - # TODO: can be fixed by flipping the conditionals auth_handler = cast("Callable[..., PreparedRequest]", auth) # Allow auth to make its changes. diff --git a/tests/test_requests.py b/tests/test_requests.py index 571535fe79..c46ff5da39 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -2124,6 +2124,34 @@ def test_basic_auth_str_is_always_native(self, username, password, auth_str): assert isinstance(s, builtin_str) assert s == auth_str + def test_callable_namedtuple_auth_uses_call_not_tuple_branch(self): + """A callable that is also a 2-tuple subclass (e.g. namedtuple) must + have its __call__ invoked, not be silently downgraded to HTTPBasicAuth. + + Regression: prepare_auth previously checked isinstance(auth, tuple) + before callable(auth), so any AuthBase subclass that also inherited + from a 2-field namedtuple would have its __call__ bypassed and its + fields extracted as Basic Auth credentials instead. + """ + from collections import namedtuple + from requests.auth import AuthBase + + _Base = namedtuple("_TokenAuth", ["token", "scheme"]) + + class TokenAuth(_Base, AuthBase): + def __call__(self, r): + r.headers["Authorization"] = f"{self.scheme} {self.token}" + return r + + auth = TokenAuth(token="my-secret", scheme="Bearer") + assert isinstance(auth, tuple) # confirm the ambiguous case + assert callable(auth) + + p = requests.Request("GET", "http://example.com", auth=auth).prepare() + + assert p.headers["Authorization"] == "Bearer my-secret" + assert not p.headers["Authorization"].startswith("Basic") + def test_requests_history_is_saved(self, httpbin): r = requests.get(httpbin("redirect/5")) total = r.history[-1].history