Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework of the URL subtraction feature #1392

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Draft
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
22 changes: 22 additions & 0 deletions CHANGES/1392.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Added support for using the :meth:`subtraction operator <yarl.URL.__sub__>`
to get the relative path between URLs.

Note that both URLs must have the same scheme, user, password, host and port:

.. code-block:: pycon

>>> target = URL("http://example.com/path/index.html")
>>> base = URL("http://example.com/")
>>> target - base
URL('path/index.html')

URLs can also be relative:

.. code-block:: pycon

>>> target = URL("/")
>>> base = URL("/path/index.html")
>>> target - base
URL('..')

-- by :user:`oleksbabieiev`
14 changes: 14 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,20 @@ The path is encoded if needed.
>>> base.join(URL('//python.org/page.html'))
URL('http://python.org/page.html')

.. method:: URL.__sub__(url)

Return a new URL with a relative *path* between two other URL objects.
*scheme*, *user*, *password*, *host*, *port*, *query* and *fragment* are removed.

.. doctest::

>>> target = URL('http://example.com/path/index.html')
>>> base = URL('http://example.com/')
>>> target - base
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also document what happens when you do base - target?

URL('path/index.html')

.. versionadded:: 1.18

Human readable representation
-----------------------------

Expand Down
83 changes: 83 additions & 0 deletions tests/test_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,89 @@ def test_str():
assert str(url) == "http://example.com:8888/path/to?a=1&b=2"


@pytest.mark.parametrize(
("target", "base", "expected"),
[
("http://example.com/path/to", "http://example.com/", "path/to"),
("http://example.com/path/to", "http://example.com/spam", "path/to"),
("http://example.com/this/is/a/test", "http://example.com/this/", "is/a/test"),
(
"http://example.com/this/./is/a/test",
"http://example.com/this/",
"is/a/test",
),
("http://example.com/this/is/../a//test", "http://example.com/this/", "a/test"),
("http://example.com/path/to", "http://example.com/spam/", "../path/to"),
("http://example.com/path", "http://example.com/path/to/", ".."),
("http://example.com/path", "http://example.com/other/../path/to/", ".."),
("http://example.com/", "http://example.com/", "."),
("http://example.com", "http://example.com", "."),
("http://example.com/", "http://example.com", "."),
("http://example.com", "http://example.com/", "."),
("//example.com", "//example.com", "."),
("/path/to", "/spam/", "../path/to"),
("path/to", "spam/", "../path/to"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no path

("/", "/to", ".."),
("/", "/path/to", "../.."),

normal

("/path", "/path/to", ".."),

trailing / - empy segment at the end

("/path", "/path/", ".."),
("/path", "/path/to/", "../.."),

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

("path/../to", "path/", "../to"),
("path/..", ".", "path/.."),
("path/../replace/me", "path/../replace", "replace/me"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this different to the one below?
("path/../replace/me", "path/../replace/", "me"),

("path/../replace/me", "path/../replace/", "me"),
("path/to", "spam", "path/to"),
("..", ".", ".."),
(".", "..", "."),
],
)
def test_sub(target: str, base: str, expected: str):
expected_url = URL(expected)
result_url = URL(target) - URL(base)
assert result_url == expected_url


@pytest.mark.xfail(reason="Empty segments are not preserved")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does need to be done to fix this? It'd be useful to include this information into the reason so that it's obvious when it can be removed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is bugs to address - c.f. #1388 (comment)

@pytest.mark.parametrize(
("target", "base", "expected"),
[
(
"http://example.com/////path/////to",
"http://example.com/////spam",
"path/////to",
),
(
"http://example.com////path/////to",
"http://example.com/////spam",
"..//path/////to",
),
],
)
def test_sub_empty_segments(target: str, base: str, expected: str):
expected_url = URL(expected)
result_url = URL(target) - URL(base)
assert result_url == expected_url


def test_sub_with_different_schemes():
expected_error_msg = r"^Both URLs should have the same scheme$"
with pytest.raises(ValueError, match=expected_error_msg):
URL("http://example.com/") - URL("https://example.com/")


def test_sub_with_different_netlocs():
expected_error_msg = r"^Both URLs should have the same netloc$"
with pytest.raises(ValueError, match=expected_error_msg):
URL("https://spam.com/") - URL("https://ham.com/")


def test_sub_with_different_anchors():
expected_error_msg = r"^'path/to' and '/path' have different anchors$"
with pytest.raises(ValueError, match=expected_error_msg):
URL("path/to") - URL("/path/from")


def test_sub_with_two_dots_in_base():
expected_error_msg = r"^'..' segment in '/path/..' cannot be walked$"
with pytest.raises(ValueError, match=expected_error_msg):
URL("path/to") - URL("/path/../from")


def test_repr():
url = URL("http://example.com")
assert "URL('http://example.com')" == repr(url)
Expand Down
17 changes: 17 additions & 0 deletions tests/test_url_benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
QUERY_URL = URL(QUERY_URL_STR)
URL_WITH_PATH_STR = "http://www.domain.tld/req"
URL_WITH_PATH = URL(URL_WITH_PATH_STR)
URL_WITH_LONGER_PATH = URL("http://www.domain.tld/req/req/req")
REL_URL = URL("/req")
QUERY_SEQ = {str(i): tuple(str(j) for j in range(10)) for i in range(10)}
SIMPLE_QUERY = {str(i): str(i) for i in range(10)}
SIMPLE_INT_QUERY = {str(i): i for i in range(10)}
QUERY_STRING = "x=y&z=1"
URL_VERY_LONG_PATH = URL("http://www.domain.tld/" + "req/" * 100)
URL_LONG_PATH = URL("http://www.domain.tld/" + "req/" * 30)


class _SubClassedStr(str):
Expand Down Expand Up @@ -586,6 +589,20 @@ def _run() -> None:
url.query


def test_url_subtract(benchmark: BenchmarkFixture) -> None:
@benchmark
def _run() -> None:
for _ in range(100):
URL_WITH_LONGER_PATH - URL_WITH_PATH
Dismissed Show dismissed Hide dismissed


def test_url_subtract_long_urls(benchmark: BenchmarkFixture) -> None:
@benchmark
def _run() -> None:
for _ in range(100):
URL_VERY_LONG_PATH - URL_LONG_PATH


def test_url_host_port_subcomponent(benchmark: BenchmarkFixture) -> None:
cache_non_default = URL_WITH_NOT_DEFAULT_PORT._cache
cache = BASE_URL._cache
Expand Down
30 changes: 30 additions & 0 deletions yarl/_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from collections.abc import Sequence
from contextlib import suppress
from itertools import chain
from pathlib import PurePosixPath


def normalize_path_segments(segments: Sequence[str]) -> list[str]:
Expand Down Expand Up @@ -39,3 +41,31 @@ def normalize_path(path: str) -> str:

segments = path.split("/")
return prefix + "/".join(normalize_path_segments(segments))


def calculate_relative_path(target: str, base: str) -> str:
"""Return the relative path between two other paths.

If the operation is not possible, raise ValueError.
"""

target = target or "/"
base = base or "/"

target_path = PurePosixPath(target)
base_path = PurePosixPath(base)

if base[-1] != "/":
base_path = base_path.parent

for step, path in enumerate(chain((base_path,), base_path.parents)):
if path == target_path or path in target_path.parents:
break
elif path.name == "..":
raise ValueError(f"'..' segment in {str(base_path)!r} cannot be walked")
else:
raise ValueError(
f"{str(target_path)!r} and {str(base_path)!r} have different anchors"
)
offset = len(path.parts)
return str(PurePosixPath(*("..",) * step, *target_path.parts[offset:]))
28 changes: 27 additions & 1 deletion yarl/_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
split_url,
unsplit_result,
)
from ._path import normalize_path, normalize_path_segments
from ._path import calculate_relative_path, normalize_path, normalize_path_segments
from ._query import (
Query,
QueryVariable,
Expand Down Expand Up @@ -516,6 +516,32 @@ def __truediv__(self, name: str) -> "URL":
def __mod__(self, query: Query) -> "URL":
return self.update_query(query)

def __sub__(self, other: object) -> "URL":
"""Return a new URL with a relative path between two other URL objects.

Note that both URLs must have the same scheme and netloc.

Example:
>>> target = URL("http://example.com/path/index.html")
>>> base = URL("http://example.com/")
>>> target - base
URL('path/index.html')
"""

if type(other) is not URL:
return NotImplemented

target_scheme, target_netloc, target_path, _, _ = self._val
base_scheme, base_netloc, base_path, _, _ = other._val

if target_scheme != base_scheme:
raise ValueError("Both URLs should have the same scheme")
if target_netloc != base_netloc:
raise ValueError("Both URLs should have the same netloc")

path = calculate_relative_path(target_path, base_path)
return self._from_parts("", "", path, "", "")

def __bool__(self) -> bool:
return bool(self._netloc or self._path or self._query or self._fragment)

Expand Down