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
8 changes: 8 additions & 0 deletions CHANGES/1770.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Fixed :meth:`~yarl.URL.with_path` silently turning a bare-relative URL
into an absolute-path reference when the new path did not start with
``/``. Previously, ``URL("foo/bar").with_path("abs")`` returned
``URL("/abs")``, which is a different reference (an absolute-path
reference per :rfc:`3986#section-4.2`) and resolves differently against
any base URL. The method now keeps bare-relative URLs bare-relative;
passing an explicit ``"/abs"`` continues to produce the slash-prefixed
result.
46 changes: 46 additions & 0 deletions tests/test_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -1428,6 +1428,52 @@ def test_with_path_relative() -> None:
assert str(url.with_path("/new")) == "/new"


def test_with_path_relative_keeps_relative() -> None:
# A bare-relative URL ("foo/bar") stays bare-relative after
# with_path("abs") — it does NOT silently gain a leading slash and
# become an absolute-path reference ("/abs"), which would change how
# the URL resolves against any base URL.
url = URL("foo/bar")
assert str(url.with_path("abs")) == "abs"
assert url.with_path("abs").is_absolute() is False
assert url.with_path("abs")._path == "abs"


def test_with_path_relative_keeps_relative_multi_segment() -> None:
url = URL("foo/bar")
assert str(url.with_path("a/b/c")) == "a/b/c"
assert url.with_path("a/b/c")._path == "a/b/c"


def test_with_path_relative_explicit_slash() -> None:
# Passing a slash-prefixed path to with_path() on a bare-relative URL
# still works and produces a slash-prefixed result — only the
# implicit-promotion case is fixed.
url = URL("foo/bar")
assert str(url.with_path("/abs")) == "/abs"


def test_with_path_relative_empty() -> None:
# An empty path on a bare-relative URL is unchanged — no leading
# slash is fabricated, and the result is still bare-relative.
url = URL("foo/bar")
assert str(url.with_path("")) == ""
assert url.with_path("")._path == ""


def test_with_path_dot_segment_unchanged_for_bare_relative() -> None:
# Dot-segment normalization (./..) is intentionally skipped for
# bare-relative paths so the user's segment choice is preserved
# as-is. Compare to with_path() on an absolute URL, which calls
# normalize_path() to collapse "." and ".." segments per RFC 3986.
assert str(URL("foo/bar").with_path("./baz")) == "./baz"
assert str(URL("foo/bar").with_path("../baz")) == "../baz"
# Absolute URL still normalizes.
assert str(URL("http://example.com/foo/bar").with_path("./baz")) == (
"http://example.com/baz"
)


def test_with_path_query() -> None:
url = URL("http://example.com?a=b")
assert str(url.with_path("/test")) == "http://example.com/test"
Expand Down
7 changes: 6 additions & 1 deletion yarl/_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -1210,7 +1210,12 @@ def with_path(
path = PATH_QUOTER(path)
if netloc:
path = normalize_path(path) if "." in path else path
if path and path[0] != "/":
# Only prepend "/" to keep the path absolute when the URL has a
# netloc OR the original URL already used an absolute path. A
# bare-relative URL like "foo/bar" stays bare-relative after
# .with_path("abs"), not "/abs" — the latter silently changes a
# path-relative reference into an absolute-path reference.
if path and path[0] != "/" and (netloc or self._path.startswith("/")):
path = f"/{path}"
query = self._query if keep_query else ""
fragment = self._fragment if keep_fragment else ""
Expand Down
Loading