Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Next

* Preserve explicit empty queries when parsing, rendering, and resolving URLs,
so clicking `?` clears existing query parameters.
([#207](https://github.com/python-hyper/hyperlink/issues/207))
* CPython 3.9 added to test matrix

## 21.0.0
Expand Down
10 changes: 7 additions & 3 deletions src/hyperlink/_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,9 @@ def query(self):
always strings. Values are strings when present, or None when
missing.

An absent query is ``()``. An explicit empty query (a trailing
``?``) has one empty key without a value: ``((u"", None),)``.

For more operations on the mapping, see
:meth:`~hyperlink.URL.get()`, :meth:`~hyperlink.URL.add()`,
:meth:`~hyperlink.URL.set()`, and
Expand Down Expand Up @@ -1428,14 +1431,15 @@ def from_text(cls, text):
else:
path = ()
rooted = bool(au_text)
if gs["query"]:
query_text = cast(Optional[Text], gs["query"])
if query_text is not None:
query = tuple(
(
qe.split(u"=", 1) # type: ignore[misc]
if u"=" in qe
else (qe, None)
)
for qe in gs["query"].split(u"&")
for qe in query_text.split(u"&")
) # type: QueryPairs
else:
query = ()
Expand Down Expand Up @@ -1801,7 +1805,7 @@ def to_text(self, with_password=False):
if scheme and authority and path[:1] != "/":
_add("/") # relpaths with abs authorities auto get '/'
_add(path)
if query_string:
if self.query:
_add("?")
_add(query_string)
if fragment:
Expand Down
25 changes: 24 additions & 1 deletion src/hyperlink/test/test_decoded_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import unicode_literals

from typing import Dict, Union
from typing import Dict, Text, Tuple, Union
from .. import DecodedURL, URL
from .._url import _percent_decode
from .common import HyperlinkTestCase
Expand All @@ -15,6 +15,29 @@


class TestURL(HyperlinkTestCase):
def test_empty_query(self):
# type: () -> None
"""Decoded URLs preserve empty queries, including clicked references."""
base = DecodedURL.from_text("http://example.com/path?x=1")
reference = DecodedURL.from_text("?")
self.assertEqual(reference.query, (("", None),))
references = (
"?",
URL.from_text("?"),
reference,
) # type: Tuple[Union[Text, URL, DecodedURL], ...]
for href in references:
result = base.click(href)
self.assertEqual(result.to_text(), "http://example.com/path?")
self.assertEqual(result.replace().to_text(), result.to_text())
self.assertEqual(result.to_uri().to_text(), result.to_text())
self.assertEqual(result.to_iri().to_text(), result.to_text())
self.assertEqual(result.normalize().to_text(), result.to_text())
self.assertEqual(
result.replace(query=()).to_text(), "http://example.com/path"
)
self.assertEqual(base.to_text(), "http://example.com/path?x=1")

def test_durl_basic(self):
# type: () -> None
bdurl = DecodedURL.from_text(BASIC_URL)
Expand Down
61 changes: 61 additions & 0 deletions src/hyperlink/test/test_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,67 @@ def test_roundtrip_double_iri(self):
assert iri_text == double_iri_text
return

def test_empty_query_roundtrip(self):
# type: () -> None
"""An explicit empty query survives parsing and URL transformations."""
for text in (
"?",
"?#fragment",
"/path?",
"/path?#fragment",
"http://example.com/path?",
"http://example.com/path?#fragment",
):
url = URL.from_text(text)
self.assertEqual(url.query, (("", None),))
for transformed in (
url,
url.replace(),
url.to_uri(),
url.to_iri(),
url.normalize(path=False),
):
self.assertEqual(transformed.to_text(), text)

def test_empty_query_parameters(self):
# type: () -> None
"""An empty query differs from no query and uses normal query APIs."""
absent = URL.from_text("/path")
empty = absent.replace(query=(("", None),))
self.assertEqual(empty.to_text(), "/path?")
self.assertEqual(empty, URL.from_text("/path?"))
self.assertNotEqual(empty, absent)
self.assertEqual(empty.get(""), [None])
self.assertEqual(empty.add("x", "1").remove("x"), empty)
self.assertEqual(empty.set("x", "1").remove("x"), empty)
self.assertEqual(empty.set("", "value").to_text(), "/path?=value")
self.assertEqual(empty.set("", None), empty)
self.assertEqual(empty.remove("").to_text(), "/path")
self.assertEqual(empty.replace(query=()).to_text(), "/path")
self.assertEqual(URL.from_text("/path?=").query, (("", ""),))
self.assertEqual(URL.from_text("/path?&").query, (("", None),) * 2)

def test_click_empty_query(self):
# type: () -> None
"""A defined empty query replaces, rather than inherits, the query."""
base_text = "http://example.com/a/b?x=1#old"
base = URL.from_text(base_text)
for reference, expected in (
("?", "http://example.com/a/b?"),
("?#new", "http://example.com/a/b?#new"),
("/next?", "http://example.com/next?"),
("../next?", "http://example.com/next?"),
("http://other.example/next?", "http://other.example/next?"),
("#new", "http://example.com/a/b?x=1#new"),
("?y=2", "http://example.com/a/b?y=2"),
("next", "http://example.com/a/next"),
):
self.assertEqual(base.click(reference).to_text(), expected)
self.assertEqual(
base.click(URL.from_text(reference)).to_text(), expected
)
self.assertEqual(base.to_text(), base_text)

def test_equality(self):
# type: () -> None
"""
Expand Down