diff --git a/CHANGES/111.feature.rst b/CHANGES/111.feature.rst new file mode 120000 index 000000000..96c7efc7c --- /dev/null +++ b/CHANGES/111.feature.rst @@ -0,0 +1 @@ +1421.feature.rst \ No newline at end of file diff --git a/CHANGES/1421.feature.rst b/CHANGES/1421.feature.rst new file mode 100644 index 000000000..bb8f7de23 --- /dev/null +++ b/CHANGES/1421.feature.rst @@ -0,0 +1 @@ +Added ``keep_query`` and ``keep_fragment`` flags in the :py:meth:`yarl.URL.with_path`, :py:meth:`yarl.URL.with_name` and :py:meth:`yarl.URL.with_suffix` methods, allowing users to optionally retain the query string and fragment in the resulting URL when replacing the path -- by :user:`paul-nameless`. diff --git a/docs/api.rst b/docs/api.rst index f9698fb37..69417bdd5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -652,10 +652,16 @@ section generates a new :class:`URL` instance. >>> URL('http://example.com:8888').with_port(None) URL('http://example.com') -.. method:: URL.with_path(path) +.. method:: URL.with_path(path, *, keep_query=False, keep_fragment=False) Return a new URL with *path* replaced, encode *path* if needed. + If ``keep_query=True`` or ``keep_fragment=True`` it retains the existing query or fragment in the URL. + + .. versionchanged:: 1.18 + + Added *keep_query* and *keep_fragment* parameters. + .. doctest:: >>> URL('http://example.com/').with_path('/path/to') @@ -857,13 +863,19 @@ section generates a new :class:`URL` instance. >>> URL('http://example.com/path#frag').with_fragment(None) URL('http://example.com/path') -.. method:: URL.with_name(name) +.. method:: URL.with_name(name, *, keep_query=False, keep_fragment=False) Return a new URL with *name* (last part of *path*) replaced and cleaned up *query* and *fragment* parts. Name is encoded if needed. + If ``keep_query=True`` or ``keep_fragment=True`` it retains the existing query or fragment in the URL. + + .. versionchanged:: 1.18 + + Added *keep_query* and *keep_fragment* parameters. + .. doctest:: >>> URL('http://example.com/path/to?arg#frag').with_name('new') @@ -871,13 +883,19 @@ section generates a new :class:`URL` instance. >>> URL('http://example.com/path/to').with_name("ім'я") URL('http://example.com/path/%D1%96%D0%BC%27%D1%8F') -.. method:: URL.with_suffix(suffix) +.. method:: URL.with_suffix(suffix, *, keep_query=False, keep_fragment=False) Return a new URL with *suffix* (file extension of *name*) replaced and cleaned up *query* and *fragment* parts. Name is encoded if needed. + If ``keep_query=True`` or ``keep_fragment=True`` it retains the existing query or fragment in the URL. + + .. versionchanged:: 1.18 + + Added *keep_query* and *keep_fragment* parameters. + .. doctest:: >>> URL('http://example.com/path/to?arg#frag').with_suffix('.doc') diff --git a/tests/test_url.py b/tests/test_url.py index 0a8ed9088..da68c2f2a 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -10,7 +10,7 @@ "\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f " ) _VERTICAL_COLON = "\ufe13" # normalizes to ":" -_FULL_WITH_NUMBER_SIGN = "\uFF03" # normalizes to "#" +_FULL_WITH_NUMBER_SIGN = "\uff03" # normalizes to "#" _ACCOUNT_OF = "\u2100" # normalizes to "a/c" @@ -1240,6 +1240,47 @@ def test_with_path_fragment(): assert str(url.with_path("/test")) == "http://example.com/test" +@pytest.mark.parametrize( + ("original_url", "keep_query", "keep_fragment", "expected_url"), + [ + pytest.param( + "http://example.com?a=b#frag", + True, + False, + "http://example.com/test?a=b", + id="query-only", + ), + pytest.param( + "http://example.com?a=b#frag", + False, + True, + "http://example.com/test#frag", + id="fragment-only", + ), + pytest.param( + "http://example.com?a=b#frag", + True, + True, + "http://example.com/test?a=b#frag", + id="all", + ), + pytest.param( + "http://example.com?a=b#frag", + False, + False, + "http://example.com/test", + id="none", + ), + ], +) +def test_with_path_keep_query_keep_fragment_flags( + original_url, keep_query, keep_fragment, expected_url +): + url = URL(original_url) + url2 = url.with_path("/test", keep_query=keep_query, keep_fragment=keep_fragment) + assert str(url2) == expected_url + + def test_with_path_empty(): url = URL("http://example.com/test") assert str(url.with_path("")) == "http://example.com" @@ -1319,6 +1360,47 @@ def test_with_name(): assert url2.path == "/a/c" +@pytest.mark.parametrize( + ("original_url", "keep_query", "keep_fragment", "expected_url"), + [ + pytest.param( + "http://example.com/path/to?a=b#frag", + True, + False, + "http://example.com/path/newname?a=b", + id="query-only", + ), + pytest.param( + "http://example.com/path/to?a=b#frag", + False, + True, + "http://example.com/path/newname#frag", + id="fragment-only", + ), + pytest.param( + "http://example.com/path/to?a=b#frag", + True, + True, + "http://example.com/path/newname?a=b#frag", + id="all", + ), + pytest.param( + "http://example.com/path/to?a=b#frag", + False, + False, + "http://example.com/path/newname", + id="none", + ), + ], +) +def test_with_name_keep_query_keep_fragment_flags( + original_url, keep_query, keep_fragment, expected_url +): + url = URL(original_url) + url2 = url.with_name("newname", keep_query=keep_query, keep_fragment=keep_fragment) + assert str(url2) == expected_url + + def test_with_name_for_naked_path(): url = URL("http://example.com") url2 = url.with_name("a") @@ -1409,6 +1491,47 @@ def test_with_suffix(): assert url2.path == "/a/b.c" +@pytest.mark.parametrize( + ("original_url", "keep_query", "keep_fragment", "expected_url"), + [ + pytest.param( + "http://example.com/path/to.txt?a=b#frag", + True, + False, + "http://example.com/path/to.md?a=b", + id="query-only", + ), + pytest.param( + "http://example.com/path/to.txt?a=b#frag", + False, + True, + "http://example.com/path/to.md#frag", + id="fragment-only", + ), + pytest.param( + "http://example.com/path/to.txt?a=b#frag", + True, + True, + "http://example.com/path/to.md?a=b#frag", + id="all", + ), + pytest.param( + "http://example.com/path/to.txt?a=b#frag", + False, + False, + "http://example.com/path/to.md", + id="none", + ), + ], +) +def test_with_suffix_keep_query_keep_fragment_flags( + original_url, keep_query, keep_fragment, expected_url +): + url = URL(original_url) + url2 = url.with_suffix(".md", keep_query=keep_query, keep_fragment=keep_fragment) + assert str(url2) == expected_url + + def test_with_suffix_for_naked_path(): url = URL("http://example.com") with pytest.raises(ValueError) as excinfo: diff --git a/yarl/_url.py b/yarl/_url.py index d20a61064..4bf3c2dd7 100644 --- a/yarl/_url.py +++ b/yarl/_url.py @@ -1114,7 +1114,14 @@ def with_port(self, port: Union[int, None]) -> "URL": self._scheme, netloc, self._path, self._query, self._fragment ) - def with_path(self, path: str, *, encoded: bool = False) -> "URL": + def with_path( + self, + path: str, + *, + encoded: bool = False, + keep_query: bool = False, + keep_fragment: bool = False, + ) -> "URL": """Return a new URL with path replaced.""" netloc = self._netloc if not encoded: @@ -1123,7 +1130,9 @@ def with_path(self, path: str, *, encoded: bool = False) -> "URL": path = normalize_path(path) if "." in path else path if path and path[0] != "/": path = f"/{path}" - return self._from_parts(self._scheme, netloc, path, "", "") + query = self._query if keep_query else "" + fragment = self._fragment if keep_fragment else "" + return self._from_parts(self._scheme, netloc, path, query, fragment) @overload def with_query(self, query: Query) -> "URL": ... @@ -1271,7 +1280,13 @@ def with_fragment(self, fragment: Union[str, None]) -> "URL": self._scheme, self._netloc, self._path, self._query, raw_fragment ) - def with_name(self, name: str) -> "URL": + def with_name( + self, + name: str, + *, + keep_query: bool = False, + keep_fragment: bool = False, + ) -> "URL": """Return a new URL with name (last part of path) replaced. Query and fragment parts are cleaned up. @@ -1298,9 +1313,18 @@ def with_name(self, name: str) -> "URL": parts[-1] = name if parts[0] == "/": parts[0] = "" # replace leading '/' - return self._from_parts(self._scheme, netloc, "/".join(parts), "", "") - def with_suffix(self, suffix: str) -> "URL": + query = self._query if keep_query else "" + fragment = self._fragment if keep_fragment else "" + return self._from_parts(self._scheme, netloc, "/".join(parts), query, fragment) + + def with_suffix( + self, + suffix: str, + *, + keep_query: bool = False, + keep_fragment: bool = False, + ) -> "URL": """Return a new URL with suffix (file extension of name) replaced. Query and fragment parts are cleaned up. @@ -1316,7 +1340,8 @@ def with_suffix(self, suffix: str) -> "URL": raise ValueError(f"{self!r} has an empty name") old_suffix = self.raw_suffix name = name + suffix if not old_suffix else name[: -len(old_suffix)] + suffix - return self.with_name(name) + + return self.with_name(name, keep_query=keep_query, keep_fragment=keep_fragment) def join(self, url: "URL") -> "URL": """Join URLs