Skip to content

Commit e67b0dd

Browse files
authored
Expand URL interface (#1601)
* Expand URL interface * Add URL query param manipulation methods
1 parent 2abb2f2 commit e67b0dd

File tree

2 files changed

+83
-18
lines changed

2 files changed

+83
-18
lines changed

httpx/_models.py

+48-17
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class URL:
112112
"""
113113

114114
def __init__(
115-
self, url: typing.Union["URL", str, RawURL] = "", params: QueryParamTypes = None
115+
self, url: typing.Union["URL", str, RawURL] = "", **kwargs: typing.Any
116116
) -> None:
117117
if isinstance(url, (str, tuple)):
118118
if isinstance(url, tuple):
@@ -144,14 +144,8 @@ def __init__(
144144
f"Invalid type for url. Expected str or httpx.URL, got {type(url)}: {url!r}"
145145
)
146146

147-
# Add any query parameters, merging with any in the URL if needed.
148-
if params:
149-
if self._uri_reference.query:
150-
url_params = QueryParams(self._uri_reference.query).merge(params)
151-
query_string = str(url_params)
152-
else:
153-
query_string = str(QueryParams(params))
154-
self._uri_reference = self._uri_reference.copy_with(query=query_string)
147+
if kwargs:
148+
self._uri_reference = self.copy_with(**kwargs)._uri_reference
155149

156150
@property
157151
def scheme(self) -> str:
@@ -293,12 +287,27 @@ def path(self) -> str:
293287
def query(self) -> bytes:
294288
"""
295289
The URL query string, as raw bytes, excluding the leading b"?".
296-
Note that URL decoding can only be applied on URL query strings
297-
at the point of decoding the individual parameter names/values.
290+
291+
This is neccessarily a bytewise interface, because we cannot
292+
perform URL decoding of this representation until we've parsed
293+
the keys and values into a QueryParams instance.
294+
295+
For example:
296+
297+
url = httpx.URL("https://example.com/?filter=some%20search%20terms")
298+
assert url.query == b"filter=some%20search%20terms"
298299
"""
299300
query = self._uri_reference.query or ""
300301
return query.encode("ascii")
301302

303+
@property
304+
def params(self) -> "QueryParams":
305+
"""
306+
The URL query parameters, neatly parsed and packaged into an immutable
307+
multidict representation.
308+
"""
309+
return QueryParams(self._uri_reference.query)
310+
302311
@property
303312
def raw_path(self) -> bytes:
304313
"""
@@ -382,6 +391,7 @@ def copy_with(self, **kwargs: typing.Any) -> "URL":
382391
"query": bytes,
383392
"raw_path": bytes,
384393
"fragment": str,
394+
"params": object,
385395
}
386396
for key, value in kwargs.items():
387397
if key not in allowed:
@@ -434,12 +444,28 @@ def copy_with(self, **kwargs: typing.Any) -> "URL":
434444
if kwargs.get("path") is not None:
435445
kwargs["path"] = quote(kwargs["path"])
436446

437-
# Ensure query=<str> for rfc3986
438447
if kwargs.get("query") is not None:
448+
# Ensure query=<str> for rfc3986
439449
kwargs["query"] = kwargs["query"].decode("ascii")
440450

451+
if "params" in kwargs:
452+
params = kwargs.pop("params")
453+
kwargs["query"] = None if not params else str(QueryParams(params))
454+
441455
return URL(self._uri_reference.copy_with(**kwargs).unsplit())
442456

457+
def copy_set_param(self, key: str, value: typing.Any = None) -> "URL":
458+
return self.copy_with(params=self.params.set(key, value))
459+
460+
def copy_add_param(self, key: str, value: typing.Any = None) -> "URL":
461+
return self.copy_with(params=self.params.add(key, value))
462+
463+
def copy_remove_param(self, key: str) -> "URL":
464+
return self.copy_with(params=self.params.remove(key))
465+
466+
def copy_merge_params(self, params: QueryParamTypes) -> "URL":
467+
return self.copy_with(params=self.params.merge(params))
468+
443469
def join(self, url: URLTypes) -> "URL":
444470
"""
445471
Return an absolute URL, using this URL as the base.
@@ -595,7 +621,7 @@ def get(self, key: typing.Any, default: typing.Any = None) -> typing.Any:
595621
return self._dict[str(key)][0]
596622
return default
597623

598-
def get_list(self, key: typing.Any) -> typing.List[str]:
624+
def get_list(self, key: str) -> typing.List[str]:
599625
"""
600626
Get all values from the query param for a given key.
601627
@@ -606,7 +632,7 @@ def get_list(self, key: typing.Any) -> typing.List[str]:
606632
"""
607633
return list(self._dict.get(str(key), []))
608634

609-
def set(self, key: typing.Any, value: typing.Any = None) -> "QueryParams":
635+
def set(self, key: str, value: typing.Any = None) -> "QueryParams":
610636
"""
611637
Return a new QueryParams instance, setting the value of a key.
612638
@@ -621,7 +647,7 @@ def set(self, key: typing.Any, value: typing.Any = None) -> "QueryParams":
621647
q._dict[str(key)] = [primitive_value_to_str(value)]
622648
return q
623649

624-
def add(self, key: typing.Any, value: typing.Any = None) -> "QueryParams":
650+
def add(self, key: str, value: typing.Any = None) -> "QueryParams":
625651
"""
626652
Return a new QueryParams instance, setting or appending the value of a key.
627653
@@ -636,7 +662,7 @@ def add(self, key: typing.Any, value: typing.Any = None) -> "QueryParams":
636662
q._dict[str(key)] = q.get_list(key) + [primitive_value_to_str(value)]
637663
return q
638664

639-
def remove(self, key: typing.Any) -> "QueryParams":
665+
def remove(self, key: str) -> "QueryParams":
640666
"""
641667
Return a new QueryParams instance, removing the value of a key.
642668
@@ -681,6 +707,9 @@ def __iter__(self) -> typing.Iterator[typing.Any]:
681707
def __len__(self) -> int:
682708
return len(self._dict)
683709

710+
def __bool__(self) -> bool:
711+
return bool(self._dict)
712+
684713
def __hash__(self) -> int:
685714
return hash(str(self))
686715

@@ -971,7 +1000,9 @@ def __init__(
9711000
self.method = method.decode("ascii").upper()
9721001
else:
9731002
self.method = method.upper()
974-
self.url = URL(url, params=params)
1003+
self.url = URL(url)
1004+
if params is not None:
1005+
self.url = self.url.copy_merge_params(params=params)
9751006
self.headers = Headers(headers)
9761007
if cookies:
9771008
Cookies(cookies).set_cookie_header(self)

tests/models/test_url.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,13 @@ def test_url_eq_str():
100100
def test_url_params():
101101
url = httpx.URL("https://example.org:123/path/to/somewhere", params={"a": "123"})
102102
assert str(url) == "https://example.org:123/path/to/somewhere?a=123"
103+
assert url.params == httpx.QueryParams({"a": "123"})
103104

104105
url = httpx.URL(
105106
"https://example.org:123/path/to/somewhere?b=456", params={"a": "123"}
106107
)
107-
assert str(url) == "https://example.org:123/path/to/somewhere?b=456&a=123"
108+
assert str(url) == "https://example.org:123/path/to/somewhere?a=123"
109+
assert url.params == httpx.QueryParams({"a": "123"})
108110

109111

110112
def test_url_join():
@@ -122,6 +124,38 @@ def test_url_join():
122124
assert url.join("../../somewhere-else") == "https://example.org:123/somewhere-else"
123125

124126

127+
def test_url_set_param_manipulation():
128+
"""
129+
Some basic URL query parameter manipulation.
130+
"""
131+
url = httpx.URL("https://example.org:123/?a=123")
132+
assert url.copy_set_param("a", "456") == "https://example.org:123/?a=456"
133+
134+
135+
def test_url_add_param_manipulation():
136+
"""
137+
Some basic URL query parameter manipulation.
138+
"""
139+
url = httpx.URL("https://example.org:123/?a=123")
140+
assert url.copy_add_param("a", "456") == "https://example.org:123/?a=123&a=456"
141+
142+
143+
def test_url_remove_param_manipulation():
144+
"""
145+
Some basic URL query parameter manipulation.
146+
"""
147+
url = httpx.URL("https://example.org:123/?a=123")
148+
assert url.copy_remove_param("a") == "https://example.org:123/"
149+
150+
151+
def test_url_merge_params_manipulation():
152+
"""
153+
Some basic URL query parameter manipulation.
154+
"""
155+
url = httpx.URL("https://example.org:123/?a=123")
156+
assert url.copy_merge_params({"b": "456"}) == "https://example.org:123/?a=123&b=456"
157+
158+
125159
def test_relative_url_join():
126160
url = httpx.URL("/path/to/somewhere")
127161
assert url.join("/somewhere-else") == "/somewhere-else"

0 commit comments

Comments
 (0)