Skip to content

Commit ddf3199

Browse files
committed
Add URL.joinpath(*elements, encoding=False) to build a URL with multiple new path elements
This is analogous to `Path(...).joinpath(...)`, except that it will not accept elements that start with `/`.
1 parent 1a43a04 commit ddf3199

File tree

5 files changed

+157
-21
lines changed

5 files changed

+157
-21
lines changed

CHANGES/704.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added ``URL.joinpath(*elements)``, to create a new URL appending multiple path elements.

docs/api.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,30 @@ The path is encoded if needed.
817817
>>> url
818818
URL('http://example.com/path/%D1%81%D1%8E%D0%B4%D0%B0')
819819

820+
.. method:: URL.joinpath(*other, encoded=False)
821+
822+
Construct a new URL by with all ``other`` elements appended to
823+
*path*, and cleaned up *query* and *fragment* parts.
824+
825+
Passing ``encoded=True`` parameter prevents path element auto-encoding, user is
826+
responsible for taking care of URL correctness.
827+
828+
.. doctest::
829+
830+
>>> url = URL('http://example.com/path?arg#frag').joinpath('to', 'subpath')
831+
>>> url
832+
URL('http://example.com/path/to/subpath')
833+
>>> url.parts
834+
('/', 'path', 'to', 'subpath')
835+
>>> url = URL('http://example.com/path?arg#frag').joinpath('сюда')
836+
>>> url
837+
URL('http://example.com/path/%D1%81%D1%8E%D0%B4%D0%B0')
838+
>>> url = URL('http://example.com/path').joinpath('%D1%81%D1%8E%D0%B4%D0%B0', encoded=True)
839+
>>> url
840+
URL('http://example.com/path/%D1%81%D1%8E%D0%B4%D0%B0')
841+
842+
.. versionadded:: 1.9
843+
820844
.. method:: URL.join(url)
821845

822846
Construct a full (“absolute”) URL by combining a “base URL”

tests/test_url.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,96 @@ def test_div_with_dots():
767767
assert url.raw_path == "/path/to"
768768

769769

770+
# joinpath
771+
772+
773+
def test_joinpath_root():
774+
url = URL("http://example.com")
775+
assert str(url.joinpath("path", "to")) == "http://example.com/path/to"
776+
777+
778+
def test_joinpath_root_with_slash():
779+
url = URL("http://example.com/")
780+
assert str(url.joinpath("path", "to")) == "http://example.com/path/to"
781+
782+
783+
def test_joinpath():
784+
url = URL("http://example.com/path")
785+
assert str(url.joinpath("to")) == "http://example.com/path/to"
786+
787+
788+
def test_joinpath_with_slash():
789+
url = URL("http://example.com/path/")
790+
assert str(url.joinpath("to")) == "http://example.com/path/to"
791+
792+
793+
def test_joinpath_path_starting_from_slash_is_forbidden():
794+
url = URL("http://example.com/path/")
795+
with pytest.raises(ValueError):
796+
assert url.joinpath("/to/others")
797+
798+
799+
def test_joinpath_cleanup_query_and_fragment():
800+
url = URL("http://example.com/path?a=1#frag")
801+
assert str(url.joinpath("to")) == "http://example.com/path/to"
802+
803+
804+
def test_joinpath_for_empty_url():
805+
url = URL().joinpath("a")
806+
assert url.raw_parts == ("a",)
807+
808+
809+
def test_joinpath_for_relative_url():
810+
url = URL("a").joinpath("b")
811+
assert url.raw_parts == ("a", "b")
812+
813+
814+
def test_joinpath_for_relative_url_started_with_slash():
815+
url = URL("/a").joinpath("b")
816+
assert url.raw_parts == ("/", "a", "b")
817+
818+
819+
def test_joinpath_non_ascii():
820+
url = URL("http://example.com/сюда")
821+
url2 = url.joinpath("туда")
822+
assert url2.path == "/сюда/туда"
823+
assert url2.raw_path == "/%D1%81%D1%8E%D0%B4%D0%B0/%D1%82%D1%83%D0%B4%D0%B0"
824+
assert url2.parts == ("/", "сюда", "туда")
825+
assert url2.raw_parts == (
826+
"/",
827+
"%D1%81%D1%8E%D0%B4%D0%B0",
828+
"%D1%82%D1%83%D0%B4%D0%B0",
829+
)
830+
831+
832+
def test_joinpath_percent_encoded():
833+
url = URL("http://example.com/path")
834+
url2 = url.joinpath("%cf%80")
835+
assert url2.path == "/path/%cf%80"
836+
assert url2.raw_path == "/path/%25cf%2580"
837+
assert url2.parts == ("/", "path", "%cf%80")
838+
assert url2.raw_parts == ("/", "path", "%25cf%2580")
839+
840+
841+
def test_joinpath_encoded_percent_encoded():
842+
url = URL("http://example.com/path")
843+
url2 = url.joinpath("%cf%80", encoded=True)
844+
assert url2.path == "/path/π"
845+
assert url2.raw_path == "/path/%cf%80"
846+
assert url2.parts == ("/", "path", "π")
847+
assert url2.raw_parts == ("/", "path", "%cf%80")
848+
849+
850+
def test_joinpath_with_colon_and_at():
851+
url = URL("http://example.com/base").joinpath("path:abc@123")
852+
assert url.raw_path == "/base/path:abc@123"
853+
854+
855+
def test_joinpath_with_dots():
856+
url = URL("http://example.com/base").joinpath("..", "path", ".", "to")
857+
assert url.raw_path == "/path/to"
858+
859+
770860
# with_path
771861

772862

yarl/__init__.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ class URL:
9696
def with_name(self, name: str) -> URL: ...
9797
def with_suffix(self, suffix: str) -> URL: ...
9898
def join(self, url: URL) -> URL: ...
99+
def joinpath(self, *url: str) -> URL: ...
99100
def human_repr(self) -> str: ...
100101
# private API
101102
@classmethod

yarl/_url.py

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -315,25 +315,7 @@ def __gt__(self, other):
315315
return self._val > other._val
316316

317317
def __truediv__(self, name):
318-
name = self._PATH_QUOTER(name)
319-
if name.startswith("/"):
320-
raise ValueError(
321-
f"Appending path {name!r} starting from slash is forbidden"
322-
)
323-
path = self._val.path
324-
if path == "/":
325-
new_path = "/" + name
326-
elif not path and not self.is_absolute():
327-
new_path = name
328-
else:
329-
parts = path.rstrip("/").split("/")
330-
parts.append(name)
331-
new_path = "/".join(parts)
332-
if self.is_absolute():
333-
new_path = self._normalize_path(new_path)
334-
return URL(
335-
self._val._replace(path=new_path, query="", fragment=""), encoded=True
336-
)
318+
return self._make_child((name,))
337319

338320
def __mod__(self, query):
339321
return self.update_query(query)
@@ -701,11 +683,45 @@ def _validate_authority_uri_abs_path(host, path):
701683
"Path in a URL with authority should start with a slash ('/') if set"
702684
)
703685

686+
def _make_child(self, segments, encoded=False):
687+
# add segments to self._val.path, accounting for absolute vs relative paths
688+
parsed = []
689+
for seg in reversed(segments):
690+
if not seg:
691+
continue
692+
if seg[0] == "/":
693+
raise ValueError(
694+
f"Appending path {seg!r} starting from slash is forbidden"
695+
)
696+
seg = seg if encoded else self._PATH_QUOTER(seg)
697+
if "/" in seg:
698+
parsed += (
699+
sub for sub in reversed(seg.split("/")) if sub and sub != "."
700+
)
701+
elif seg != ".":
702+
parsed.append(seg)
703+
parsed.reverse()
704+
old_path = self._val.path
705+
if old_path:
706+
parsed = [*old_path.rstrip("/").split("/"), *parsed]
707+
if self.is_absolute():
708+
parsed = self._normalize_path_segments(parsed)
709+
new_path = "/".join(parsed)
710+
return URL(
711+
self._val._replace(path=new_path, query="", fragment=""), encoded=True
712+
)
713+
704714
@classmethod
705715
def _normalize_path(cls, path):
706-
# Drop '.' and '..' from path
716+
# Drop '.' and '..' from str path
707717

708718
segments = path.split("/")
719+
return "/".join(cls._normalize_path_segments(segments))
720+
721+
@classmethod
722+
def _normalize_path_segments(cls, segments):
723+
# Drop '.' and '..' from a sequence of str segments
724+
709725
resolved_path = []
710726

711727
for seg in segments:
@@ -728,7 +744,7 @@ def _normalize_path(cls, path):
728744
# then we need to append the trailing '/'
729745
resolved_path.append("")
730746

731-
return "/".join(resolved_path)
747+
return resolved_path
732748

733749
@classmethod
734750
def _encode_host(cls, host, human=False):
@@ -1077,6 +1093,10 @@ def join(self, url):
10771093
raise TypeError("url should be URL")
10781094
return URL(urljoin(str(self), str(url)), encoded=True)
10791095

1096+
def joinpath(self, *other, encoded=False):
1097+
"""Return a new URL with the elements in other appended to the path."""
1098+
return self._make_child(other, encoded=encoded)
1099+
10801100
def human_repr(self):
10811101
"""Return decoded human readable string for URL representation."""
10821102
user = _human_quote(self.user, "#/:?@")

0 commit comments

Comments
 (0)