diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f34c6..24bf41b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/hyperlink/_url.py b/src/hyperlink/_url.py index 8797b5c..5d2b4a6 100644 --- a/src/hyperlink/_url.py +++ b/src/hyperlink/_url.py @@ -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 @@ -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 = () @@ -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: diff --git a/src/hyperlink/test/test_decoded_url.py b/src/hyperlink/test/test_decoded_url.py index 4845257..98d935d 100644 --- a/src/hyperlink/test/test_decoded_url.py +++ b/src/hyperlink/test/test_decoded_url.py @@ -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 @@ -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) diff --git a/src/hyperlink/test/test_url.py b/src/hyperlink/test/test_url.py index 37c9172..a226475 100644 --- a/src/hyperlink/test/test_url.py +++ b/src/hyperlink/test/test_url.py @@ -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 """