Repository: encode/httpx

Issue:
Percent sign not encoded when present in query params value
Since httpx 0.24 the behavior regarding query params changed, and possibly contains a bug.

Use case: passing a pre-signed storage url to some webservice which then downloads the file.

The presigned url will contain some percent-escaped sequences.

With the new httpx 0.24, when something like `client.get('http://webservice', params={"u": "http://example.com?q=foo%2Fa"}) ` is called, the query params are handed to httpx._urls.urlencode:

```
from httpx._urls import urlencode
urlencode((('u', "http://example.com?q=foo%2Fa"),))
# -> 'u=http%3A//example.com%3Fq%3Dfoo%2Fa'
```

The resulting query string still has %2F in it, which should have been percent encoded as %252F

Here is the same thing using python urllib.parse (which was used pre-0.24)
```
from urllib.parse import urlencode
urlencode((('u', "http://example.com?q=foo%2Fa"),))
# -> 'u=http%3A%2F%2Fexample.com%3Fq%3Dfoo%252Fa'
```

Here the resulting query string is correctly encoded.

Another way to look at it is as follows:

```
from urllib.parse importparse_qs
from httpx._urls import urlencode
parse_qs( urlencode((('u', "http://example.com?q=foo%2Fa%20b%2Ac"),)))
# -> {'u': ['http://example.com?q=foo/a b*c']}
# Incorrect url decoded from the query string

from urllib.parse import urlencode
parse_qs( urlencode((('u', "http://example.com?q=foo%2Fa%20b%2Ac"),)))
# -> {'u': ['http://example.com?q=foo%2Fa%20b%2Ac']}
# Correct original url decoded from the query string
```

Discussion:

Patch:
diff --git a/httpx/_urlparse.py b/httpx/_urlparse.py
index 0fbec35..6522d91 100644
--- a/httpx/_urlparse.py
+++ b/httpx/_urlparse.py
@@ -399,7 +399,7 @@ def normalize_path(path: str) -> str:
 
 def percent_encode(char: str) -> str:
     """
-    Replace every character in a string with the percent-encoded representation.
+    Replace a single character with the percent-encoded representation.
 
     Characters outside the ASCII range are represented with their a percent-encoded
     representation of their UTF-8 byte sequence.
@@ -411,13 +411,29 @@ def percent_encode(char: str) -> str:
     return "".join([f"%{byte:02x}" for byte in char.encode("utf-8")]).upper()
 
 
+def is_safe(string: str, safe: str = "/") -> bool:
+    """
+    Determine if a given string is already quote-safe.
+    """
+    NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe + "%"
+
+    # All characters must already be non-escaping or '%'
+    for char in string:
+        if char not in NON_ESCAPED_CHARS:
+            return False
+
+    # Any '%' characters must be valid '%xx' escape sequences.
+    return string.count("%") == len(PERCENT_ENCODED_REGEX.findall(string))
+
+
 def quote(string: str, safe: str = "/") -> str:
-    NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe
-    if string.count("%") == len(PERCENT_ENCODED_REGEX.findall(string)):
-        # If all occurances of '%' are valid '%xx' escapes, then treat
-        # percent as a non-escaping character.
-        NON_ESCAPED_CHARS += "%"
+    """
+    Use percent-encoding to quote a string if required.
+    """
+    if is_safe(string, safe=safe):
+        return string
 
+    NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe
     return "".join(
         [char if char in NON_ESCAPED_CHARS else percent_encode(char) for char in string]
     )


Test patch:
diff --git a/tests/test_urlparse.py b/tests/test_urlparse.py
index ec1fd20..6b5c834 100644
--- a/tests/test_urlparse.py
+++ b/tests/test_urlparse.py
@@ -126,6 +126,24 @@ def test_urlparse_leading_dot_prefix_on_relative_url():
     assert url.path == "../abc"
 
 
+# Tests for optional percent encoding
+
+
+def test_param_requires_encoding():
+    url = httpx.URL("http://webservice", params={"u": "with spaces"})
+    assert str(url) == "http://webservice?u=with%20spaces"
+
+
+def test_param_does_not_require_encoding():
+    url = httpx.URL("http://webservice", params={"u": "with%20spaces"})
+    assert str(url) == "http://webservice?u=with%20spaces"
+
+
+def test_param_with_existing_escape_requires_encoding():
+    url = httpx.URL("http://webservice", params={"u": "http://example.com?q=foo%2Fa"})
+    assert str(url) == "http://webservice?u=http%3A//example.com%3Fq%3Dfoo%252Fa"
+
+
 # Tests for invalid URLs