Repository: jawah/niquests

Issue:
CaseInsensitiveDict.items() can return a mix of lists/tuples
https://github.com/jawah/niquests/blob/43065c574c8ffc904dc99ca909fabb2650386e93/src/niquests/structures.py#L127-L139

In the event there are multiple values for a header (i.e. `len(t) > 2`), it always returns a tuple.
However, for single values, it seems to return a list. This inconsistency can cause issues when doing comparisons, such as `sorted(r.headers.items())` (requests-cache does this).

Didn't dive too deep into it, so as a workaround, I just `yield tuple(t)` for now, but I'm not sure if there's a better fix somewhere upstream since it does seem to initially be set as a tuple but is somehow changing into a list.

## Expected Result

All items should be tuples.

e.g. for https://www.google.com/

```
[('date', 'Fri, 15 Aug 2025 18:53:57 GMT'),
 ('expires', '-1'),
 ('cache-control', 'private, max-age=0'),
 ('content-type', 'text/html; charset=ISO-8859-1'),
 ('content-security-policy-report-only',
  "object-src 'none';base-uri 'self';script-src 'nonce-8GX37TwO659uL4vjCrWegw' "
  "'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: "
  'http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp'),
 ('accept-ch', 'Sec-CH-Prefers-Color-Scheme'),
 ('p3p', 'CP="This is not a P3P policy! See g.co/p3phelp for more info."'),
 ('content-encoding', 'gzip'),
 ('server', 'gws'),
 ('x-xss-protection', '0'),
 ('x-frame-options', 'SAMEORIGIN'),
 ('set-cookie',
  'AEC=AVh_V2h_FznYfTMbjMuAWK_avTarF42PkWMBOSylQbxny0lHfUPi7mYzlkw; '
  'expires=Wed, 11-Feb-2026 18:53:58 GMT; path=/; domain=.google.com; Secure; '
  'HttpOnly; SameSite=lax, '
  'NID=525=S1n3MgVrtEhg-PZNxC_-0VaDtvZUmPynoHYEBctGnUDUczMpA6IMGLLHfR8gQIV5GiBn9m2ipr6T4kt_U-V7dHHIndJ-DFzdQ6vJY4g83tvLswpgII5BoOgAUwVOPPc-ioH-3bXMQLvXTrmgjUCWtm9XzvoGSAVJVQLNCRtT8IIkspOQtxlNM_sRc6TVvCpNo8F9R_QSdLRtOzqTRs4cA-g2; '
  'expires=Sat, 14-Feb-2026 18:53:57 GMT; path=/; domain=.google.com; '
  'HttpOnly'),
 ('alt-svc', 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000')]
```


## Actual Result

There can be a mix of lists and tuples.
```
[['date', 'Fri, 15 Aug 2025 18:53:57 GMT'],
 ['expires', '-1'],
 ['cache-control', 'private, max-age=0'],
 ['content-type', 'text/html; charset=ISO-8859-1'],
 ['content-security-policy-report-only',
  "object-src 'none';base-uri 'self';script-src 'nonce-8GX37TwO659uL4vjCrWegw' "
  "'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: "
  'http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp'],
 ['accept-ch', 'Sec-CH-Prefers-Color-Scheme'],
 ['p3p', 'CP="This is not a P3P policy! See g.co/p3phelp for more info."'],
 ['content-encoding', 'gzip'],
 ['server', 'gws'],
 ['x-xss-protection', '0'],
 ['x-frame-options', 'SAMEORIGIN'],
 ('set-cookie',
  'AEC=AVh_V2h_FznYfTMbjMuAWK_avTarF42PkWMBOSylQbxny0lHfUPi7mYzlkw; '
  'expires=Wed, 11-Feb-2026 18:53:58 GMT; path=/; domain=.google.com; Secure; '
  'HttpOnly; SameSite=lax, '
  'NID=525=S1n3MgVrtEhg-PZNxC_-0VaDtvZUmPynoHYEBctGnUDUczMpA6IMGLLHfR8gQIV5GiBn9m2ipr6T4kt_U-V7dHHIndJ-DFzdQ6vJY4g83tvLswpgII5BoOgAUwVOPPc-ioH-3bXMQLvXTrmgjUCWtm9XzvoGSAVJVQLNCRtT8IIkspOQtxlNM_sRc6TVvCpNo8F9R_QSdLRtOzqTRs4cA-g2; '
  'expires=Sat, 14-Feb-2026 18:53:57 GMT; path=/; domain=.google.com; '
  'HttpOnly'),
 ['alt-svc', 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000']]
```
Notice set-cookie has multiple values and is a tuple

## Reproduction Steps

```python
import niquests
from pprint import pprint

r = niquests.get('https://www.google.com/')
pprint(list(r.headers.items())
pprint(sorted(r.headers.items())

```
Will produce
```
Traceback (most recent call last):
  File "<python-input-20>", line 1, in <module>
    pprint(sorted(r.headers.items()))
           ~~~~~~^^^^^^^^^^^^^^^^^^^
TypeError: '<' not supported between instances of 'tuple' and 'list'
```


## System Information

    $ python -m niquests.help

```
{
  "charset_normalizer": {
    "version": "3.4.3"
  },
  "gil": true,
  "http1": {
    "h11": "0.16.0"
  },
  "http2": {
    "jh2": "5.0.9"
  },
  "http3": {
    "enabled": true,
    "qh3": "1.5.4"
  },
  "idna": {
    "version": "3.10"
  },
  "implementation": {
    "name": "CPython",
    "version": "3.13.6"
  },
  "niquests": {
    "version": "3.15.1"
  },
  "ocsp": {
    "enabled": true
  },
  "platform": {
    "release": "6.8.12-3-pve",
    "system": "Linux"
  },
  "system_ssl": {
    "name": "OpenSSL 3.0.2 15 Mar 2022",
    "version": "30000020"
  },
  "urllib3.future": {
    "cohabitation_version": null,
    "version": "2.13.903"
  },
  "wassima": {
    "version": "2.0.1"
  },
  "websocket": {
    "enabled": false,
    "wsproto": null
  }
}
```


Discussion:

Fix (patch):
diff --git a/HISTORY.md b/HISTORY.md
index 74004e86..c4f42213 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,6 +1,12 @@
 Release History
 ===============
 
+3.15.2 (2025-08-??)
+-------------------
+
+**Fixed**
+- The return type of `CaseInsensitiveDict.items()` could be a `list` instead of an expected `tuple`. (#276)
+
 3.15.1 (2025-08-13)
 -------------------
 
diff --git a/src/niquests/structures.py b/src/niquests/structures.py
index 6d44ea8a..f199621c 100644
--- a/src/niquests/structures.py
+++ b/src/niquests/structures.py
@@ -128,7 +128,7 @@ class CaseInsensitiveDict(MutableMapping):
         for k in self._store:
             t = self._store[k]
             if len(t) == 2:
-                yield t
+                yield tuple(t)
             else:  # this case happen due to copying "_container" from HTTPHeaderDict!
                 try:
                     yield t[0], ", ".join(t[1:])  # type: ignore[arg-type]


Test changes:
diff --git a/tests/test_structures.py b/tests/test_structures.py
index 808110f1..80c5845c 100644
--- a/tests/test_structures.py
+++ b/tests/test_structures.py
@@ -59,12 +59,16 @@ class TestCaseInsensitiveDict:
         o.add("Hello", "1")
         o.add("Hello", "2")
         o.add("Hello", "3")
+        o.add("FooBar", "Baz")
 
         u = CaseInsensitiveDict(o)
 
         assert u["Hello"] == "1, 2, 3"
         assert "1, 2, 3" in repr(u)
 
+        for item in u.items():
+            assert isinstance(item, tuple)
+
 
 class TestLookupDict:
     @pytest.fixture(autouse=True)