From 5e5393a5f64c9fe4e7a6653dcf777d791af2e49a Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sun, 8 Nov 2026 13:10:41 +0800 Subject: [PATCH] Preserve explicit empty URL queries Keep absent and empty queries distinct during parsing and rendering so relative references can clear an inherited query. Cover URL and DecodedURL round trips, query operations, and reference resolution. Fixes #207 --- CHANGELOG.md | 3 ++ src/hyperlink/_url.py | 10 +++-- src/hyperlink/test/test_decoded_url.py | 25 ++++++++++- src/hyperlink/test/test_url.py | 61 ++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f34c6a..24bf41bd 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 8797b5cc..5d2b4a6c 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 48452579..98d935d5 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 37c91726..a2264751 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 """