Repository: encode/httpx

Issue:
Can't handle URLs with @ character in userinfo section
```python
>>> import httpx
>>> httpx.URL("https://user@gmail.com:password@google.com")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/sethmlarson/Desktop/http3/httpx/models.py", line 112, in __init__
    raise InvalidURL("No host included in URL.")
httpx.exceptions.InvalidURL: No host included in URL.
```

This is a common use-case for proxies and websites using email addresses as usernames for authentication.

This might require a change in `rfc3986` or the way we parse URLs. :)


Discussion:
Hi @sethmlarson I've pushed a [PR](https://github.com/python-hyper/rfc3986/pull/59) about this topic. Could your review it?
So, something I think we may want to re-review prior to 1.0 is which internal URL parsing implementation is really our best option. This issue (and perhaps also #858, although maybe that's exclusively an issue on our side?) currently means that we're a little less robust on URL parsing than the wonderfully battle hardened `urllib3`, which is something we ought to resolve.

Making a change here wouldn't have any affect on our public API. That's tied down and looks that way we want. But this could potentially change which dependancies we choose to ship with.

Some options here are...

* Stick with `rfc3986`. Because we're doing just fine really. Let's just make sure we're contributing our time to helping getting this issue resolved.
* Consider `irl`.
* Switch to the `urllib3` implementation, possibly by vendoring just the URL parsing into a (properly licensed and credited) slimline stand-alone package that just implements plain functions for `url_parse` and `url_join`, that only deal with super plain named-tuple-like arguments.

Of those three options, the last seems like it'd be the most robust approach, since `urllib3` have been doing this for *years*. Having said that, I don't really have enough context about which of these three options the `urllib3` team themselves would consider to be most suitable.

No doubt @sigmavirus24 and @sethmlarson both have far more insight into this, tho I don't want to push for anyone's time here.

Resolving patch:
diff --git a/httpx/_models.py b/httpx/_models.py
--- a/httpx/_models.py
+++ b/httpx/_models.py
@@ -7,7 +7,7 @@
 import warnings
 from collections.abc import MutableMapping
 from http.cookiejar import Cookie, CookieJar
-from urllib.parse import parse_qsl, urlencode
+from urllib.parse import parse_qsl, quote, unquote, urlencode
 
 import chardet
 import rfc3986
@@ -100,12 +100,12 @@ def userinfo(self) -> str:
     @property
     def username(self) -> str:
         userinfo = self._uri_reference.userinfo or ""
-        return userinfo.partition(":")[0]
+        return unquote(userinfo.partition(":")[0])
 
     @property
     def password(self) -> str:
         userinfo = self._uri_reference.userinfo or ""
-        return userinfo.partition(":")[2]
+        return unquote(userinfo.partition(":")[2])
 
     @property
     def host(self) -> str:
@@ -175,8 +175,8 @@ def copy_with(self, **kwargs: typing.Any) -> "URL":
         ):
             host = kwargs.pop("host", self.host)
             port = kwargs.pop("port", self.port)
-            username = kwargs.pop("username", self.username)
-            password = kwargs.pop("password", self.password)
+            username = quote(kwargs.pop("username", self.username) or "")
+            password = quote(kwargs.pop("password", self.password) or "")
 
             authority = host
             if port is not None:
@@ -193,7 +193,7 @@ def copy_with(self, **kwargs: typing.Any) -> "URL":
 
     def join(self, url: URLTypes) -> "URL":
         """
-        Return an absolute URL, using given this URL as the base.
+        Return an absolute URL, using this URL as the base.
         """
         if self.is_relative_url:
             return URL(url)


Test patch:
diff --git a/tests/models/test_url.py b/tests/models/test_url.py
--- a/tests/models/test_url.py
+++ b/tests/models/test_url.py
@@ -187,6 +187,18 @@ def test_url_copywith_for_authority():
     assert str(new) == "https://username:password@example.net:444"
 
 
+def test_url_copywith_for_userinfo():
+    copy_with_kwargs = {
+        "username": "tom@example.org",
+        "password": "abc123@ %",
+    }
+    url = URL("https://example.org")
+    new = url.copy_with(**copy_with_kwargs)
+    assert str(new) == "https://tom%40example.org:abc123%40%20%25@example.org"
+    assert new.username == "tom@example.org"
+    assert new.password == "abc123@ %"
+
+
 def test_url_invalid():
     with pytest.raises(InvalidURL):
         URL("https://😇/")