Repository: encode/httpx

Issue:
Should `Headers.keys` be a set-like keys view?
### Checklist

<!-- To help keep this issue tracker clean and focused, please make sure you tried *all* the following resources before submitting your question. -->

- [X] I searched the [HTTPX documentation](https://www.python-httpx.org) but couldn't find what I'm looking for.
- [X] I looked through similar issues on GitHub, but didn't find anything.
- [ ] I looked up "How to do ... in HTTPX" on a search engine and didn't find any information.
- [ ] I asked the [community chat](https://gitter.im/encode/community) for help but didn't get an answer.

### Question

presently [`Headers.keys`](https://github.com/encode/httpx/blob/master/httpx/_models.py#L458) returns a `typing.List[str]` rather than a (set-like) `typing.KeysView[str]`, which appears to violate the `Mapping` protocol: https://docs.python.org/3/library/collections.abc.html#collections.abc.KeysView

this breaks code that expects set-like operations with `Headers.keys`, e.g.
```
    dst: Dict[str, str] = ...
    headers: httpx.Headers = ...
    keys: Set[str] = {"set-cookie", "set-cookie2"}
    dst.update({k: headers[k] for k in keys & headers.keys()})
```

is this an intentional design choice or simply an oversight?


Discussion:
Not necessarily intentional, no. Any idea how we could return such a keyview object? I don't know how we can construct one from something that's not a dict.
Okay I found it — we can use `typing.KeysView(<dict>)`, `typing.ValuesView(<dict>)`, etc.

Thinking about this more, I think this was an intentional choice, basically to keep the scope down a bit.

Eg one reason why we cannot very easily implement these interfaces (eg building a dict and returning the result of calling `.keys()` on it) is because duplicate headers are not forbidden by the HTTP spec. `Set-Cookie` is a prime example of this.

That doesn't mean we can't implement the `KeysView`/`ValuesView`/`ItemsView` protocols, though, but we'd need to implement our own subclasses, and basically end up with [what `multidict` is doing](https://github.com/aio-libs/multidict/blob/master/multidict/_multidict_py.py). Making that work with type hints etc would be a significant chunk of code and work.

Don't think we're ready to make that move yet… But I'd personally be interested in seeing what making this work would take.
I think the easiest way would likely be something similar to how aiolibs `multidict` does it, yeah; using the default `KeysView`, `ValuesView` wouldn't work well for the reasons you mention although it's worth pointing out that the `XView` constructors accept general mappings (and using stale `dict` snapshots to implement the views would violate the protocol anyway).
So, switching to `multidict` is not as simple unfortunately, and probably not possible, because we already have some subtle behavior differences between our `QueryParams` and `Headers` models, and how multidict behaves.

Eg:

- Currently `params[key]` returns the first value in the list of items for `key`, while `multidict` returns the last.
- `multidict` doesn't allow efficiently iterating over key-unique items (like a dict), so we have to do `dict(md).keys()` to get unique keys for queryparams (while multikeys is what headers expect).

Resolving patch:
diff --git a/httpx/_models.py b/httpx/_models.py
--- a/httpx/_models.py
+++ b/httpx/_models.py
@@ -405,23 +405,25 @@ def raw(self) -> typing.List[typing.Tuple[bytes, bytes]]:
         """
         return self._list
 
-    def keys(self) -> typing.List[str]:  # type: ignore
-        return [key.decode(self.encoding) for key in self._dict.keys()]
+    def keys(self) -> typing.KeysView[str]:
+        return {key.decode(self.encoding): None for key in self._dict.keys()}.keys()
 
-    def values(self) -> typing.List[str]:  # type: ignore
-        return [value.decode(self.encoding) for value in self._dict.values()]
+    def values(self) -> typing.ValuesView[str]:
+        return {
+            key: value.decode(self.encoding) for key, value in self._dict.items()
+        }.values()
 
-    def items(self) -> typing.List[typing.Tuple[str, str]]:  # type: ignore
+    def items(self) -> typing.ItemsView[str, str]:
         """
-        Return a list of `(key, value)` pairs of headers. Concatenate headers
+        Return `(key, value)` items of headers. Concatenate headers
         into a single comma seperated value when a key occurs multiple times.
         """
-        return [
-            (key.decode(self.encoding), value.decode(self.encoding))
+        return {
+            key.decode(self.encoding): value.decode(self.encoding)
             for key, value in self._dict.items()
-        ]
+        }.items()
 
-    def multi_items(self) -> typing.List[typing.Tuple[str, str]]:  # type: ignore
+    def multi_items(self) -> typing.List[typing.Tuple[str, str]]:
         """
         Return a list of `(key, value)` pairs of headers. Allow multiple
         occurences of the same key without concatenating into a single
@@ -470,7 +472,7 @@ def update(self, headers: HeaderTypes = None) -> None:  # type: ignore
             self[header] = headers[header]
 
     def copy(self) -> "Headers":
-        return Headers(self.items(), encoding=self.encoding)
+        return Headers(dict(self.items()), encoding=self.encoding)
 
     def __getitem__(self, key: str) -> str:
         """


Test patch:
diff --git a/tests/models/test_headers.py b/tests/models/test_headers.py
--- a/tests/models/test_headers.py
+++ b/tests/models/test_headers.py
@@ -14,9 +14,9 @@ def test_headers():
     assert h.get("a") == "123, 456"
     assert h.get("nope", default=None) is None
     assert h.get_list("a") == ["123", "456"]
-    assert h.keys() == ["a", "b"]
-    assert h.values() == ["123, 456", "789"]
-    assert h.items() == [("a", "123, 456"), ("b", "789")]
+    assert list(h.keys()) == ["a", "b"]
+    assert list(h.values()) == ["123, 456", "789"]
+    assert list(h.items()) == [("a", "123, 456"), ("b", "789")]
     assert h.multi_items() == [("a", "123"), ("a", "456"), ("b", "789")]
     assert list(h) == ["a", "b"]
     assert dict(h) == {"a": "123, 456", "b": "789"}