From 14768b66af4a4b36c8b8b025358c90fd5460cf31 Mon Sep 17 00:00:00 2001 From: Rolando Bosch Date: Sat, 28 Feb 2026 15:16:56 -0800 Subject: [PATCH] fix: handle tuples in `deepcopy_minimal` to prevent httpx header mutation `deepcopy_minimal` copies `dict` and `list` but skips `tuple`, returning it as-is. When `files.beta.upload()` is called with a `FileTypes` tuple containing a headers dict, the tuple passes through uncopied. httpx then mutates the user's original headers dict by injecting `Content-Type` during multipart encoding, permanently contaminating user data. This adds tuple handling to `deepcopy_minimal`, matching the existing pattern for dicts and lists. Tuples are recursively copied so that mutable elements (like headers dicts) get independent copies. Note: `_utils/_utils.py` has no Stainless codegen header, consistent with hand-maintained utility code. If this change should be applied to the generation template instead, happy to redirect. Fixes #1202 Co-Authored-By: Claude Opus 4.6 --- src/anthropic/_utils/_utils.py | 3 +++ tests/test_deepcopy.py | 45 +++++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/anthropic/_utils/_utils.py b/src/anthropic/_utils/_utils.py index eec7f4a1..d66ea71d 100644 --- a/src/anthropic/_utils/_utils.py +++ b/src/anthropic/_utils/_utils.py @@ -181,6 +181,7 @@ def deepcopy_minimal(item: _T) -> _T: - mappings, e.g. `dict` - list + - tuple This is done for performance reasons. """ @@ -188,6 +189,8 @@ def deepcopy_minimal(item: _T) -> _T: return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) if is_list(item): return cast(_T, [deepcopy_minimal(entry) for entry in item]) + if is_tuple(item): + return cast(_T, tuple(deepcopy_minimal(entry) for entry in item)) return item diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py index edbbfb22..68db0112 100644 --- a/tests/test_deepcopy.py +++ b/tests/test_deepcopy.py @@ -52,7 +52,44 @@ def test_ignores_other_types() -> None: assert_different_identities(obj1, obj2) assert obj1["foo"] is my_obj - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 + +def test_simple_tuple() -> None: + obj1 = ("a", "b") + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + + +def test_tuple_with_mutable_contents() -> None: + """Tuples containing mutable objects (like FileTypes headers) must be deep copied.""" + headers = {"X-Custom": "value"} + obj1 = ("test.txt", b"hello", "text/plain", headers) + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert_different_identities(obj1[3], obj2[3]) + # Mutating the copy must not affect the original + obj2[3]["X-Injected"] = "surprise" + assert "X-Injected" not in headers + + +def test_dict_containing_tuple() -> None: + """Simulates the files.beta.upload body: deepcopy_minimal({'file': file_tuple}).""" + headers = {"Authorization": "Bearer xyz"} + file_tuple = ("doc.pdf", b"data", "application/pdf", headers) + body = {"file": file_tuple} + body_copy = deepcopy_minimal(body) + assert_different_identities(body, body_copy) + assert_different_identities(body["file"], body_copy["file"]) + assert_different_identities(body["file"][3], body_copy["file"][3]) + # Mutating the copy's headers must not affect the original + body_copy["file"][3]["X-Mutated"] = "yes" + assert "X-Mutated" not in headers + + +def test_list_of_tuples() -> None: + """Simulates the skills/versions.py body: deepcopy_minimal({'files': [file_tuple, ...]}).""" + headers = {"X-H": "v"} + files = [("a.txt", b"data", "text/plain", headers)] + body = {"files": files} + body_copy = deepcopy_minimal(body) + assert_different_identities(body_copy["files"][0], body["files"][0]) + assert_different_identities(body_copy["files"][0][3], body["files"][0][3])