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 14 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
26 changes: 26 additions & 0 deletions CHANGES/1392.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Added a new method :py:meth:`~yarl.URL.relative_to`
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.relative_to(base)
URL('path/index.html')
>>> base.relative_to(target)
URL('..')

URLs can also be relative:

.. code-block:: pycon

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

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

.. method:: URL.relative_to(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.relative_to(base)
URL('path/index.html')
>>> base.relative_to(target)
URL('..')

.. versionadded:: 1.19

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

Expand Down
6 changes: 2 additions & 4 deletions packaging/pep517_backend/_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,12 @@
from Cython.Build.Cythonize import main as _cythonize_cli_cmd

from ._compat import chdir_cm
from ._cython_configuration import ( # noqa: WPS436
get_local_cython_config as _get_local_cython_config,
)
from ._cython_configuration import get_local_cython_config as _get_local_cython_config
from ._cython_configuration import (
make_cythonize_cli_args_from_config as _make_cythonize_cli_args_from_config,
)
from ._cython_configuration import patched_env as _patched_cython_env
from ._transformers import sanitize_rst_roles # noqa: WPS436
from ._transformers import sanitize_rst_roles

__all__ = ( # noqa: WPS410
'build_sdist',
Expand Down
7 changes: 2 additions & 5 deletions packaging/pep517_backend/_cython_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,8 @@

from expandvars import expandvars

from ._compat import load_toml_from_string # noqa: WPS436
from ._transformers import ( # noqa: WPS436
get_cli_kwargs_from_config,
get_enabled_cli_flags_from_config,
)
from ._compat import load_toml_from_string
from ._transformers import get_cli_kwargs_from_config, get_enabled_cli_flags_from_config


def get_local_cython_config() -> dict:
Expand Down
4 changes: 2 additions & 2 deletions packaging/pep517_backend/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@
from setuptools.build_meta import * # Re-exporting PEP 517 hooks # pylint: disable=unused-wildcard-import,wildcard-import # noqa: E501, F401, F403

# Re-exporting PEP 517 hooks
from ._backend import ( # type: ignore[assignment] # noqa: WPS436
from ._backend import ( # type: ignore[assignment]
build_sdist,
build_wheel,
get_requires_for_build_wheel,
prepare_metadata_for_build_wheel,
)

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'build_sdist' is not used.
Import of 'build_wheel' is not used.
Import of 'get_requires_for_build_wheel' is not used.
Import of 'prepare_metadata_for_build_wheel' is not used.

with _suppress(ImportError): # Only succeeds w/ setuptools implementing PEP 660
# Re-exporting PEP 660 hooks
from ._backend import ( # type: ignore[assignment] # noqa: WPS436
from ._backend import ( # type: ignore[assignment]
build_editable,
get_requires_for_build_editable,
prepare_metadata_for_build_editable,
)

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'build_editable' is not used.
Import of 'get_requires_for_build_editable' is not used.
Import of 'prepare_metadata_for_build_editable' is not used.
63 changes: 63 additions & 0 deletions tests/test_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,69 @@ 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"),
Copy link
Contributor

Choose a reason for hiding this comment

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

should be ../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/////path/////to",
"http://example.com/////spam",
"path/////to",
),
(
"http://example.com////path/////to",
"http://example.com/////spam",
"../path/////to",
),
(
"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"),

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the last segment of the path does not have a / at the end, it is simply ignored when calculating the relative path

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In fact, we can say that if the path ends with a /, then its last segment is treated as a directory, otherwise - as a file. At least that's how it works for now 🤷‍♂️

Copy link
Contributor

Choose a reason for hiding this comment

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

pathlib PosixPath can not be used to relativize URL pathes as URL pathes can have empty segments, and PosixPath strips trailing / anyway.

Copy link
Contributor Author

@oleksbabieiev oleksbabieiev Dec 27, 2024

Choose a reason for hiding this comment

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

Yeah, I've already noticed that...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll think about what to replace it with

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I've managed to solve it

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


def test_relative_to_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/").relative_to(URL("https://example.com/"))


def test_relative_to_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/").relative_to(URL("https://ham.com/"))


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 @@ -611,6 +614,20 @@ def _run() -> None:
url.query


def test_relative_to(benchmark: BenchmarkFixture) -> None:
@benchmark
def _run() -> None:
for _ in range(100):
URL_WITH_LONGER_PATH.relative_to(URL_WITH_PATH)


def test_relative_to_long_urls(benchmark: BenchmarkFixture) -> None:
@benchmark
def _run() -> None:
for _ in range(100):
URL_VERY_LONG_PATH.relative_to(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
22 changes: 22 additions & 0 deletions yarl/_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,25 @@ 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:
"""Calculate the relative path between two other paths"""

base_segments = base.split("/")[:-1]
target_segments = target.split("/")

offset = 0
for base_seg, target_seg in zip(base_segments, target_segments):
if base_seg == target_seg:
offset += 1
else:
break

remaining_base_segments = base_segments[offset:]
remaining_target_segments = target_segments[offset:]

relative_segments = [".."] * len(remaining_base_segments)
relative_segments.extend(remaining_target_segments)

return "/".join(relative_segments)
30 changes: 29 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 @@ -1365,6 +1365,34 @@ def with_suffix(

return self.with_name(name, keep_query=keep_query, keep_fragment=keep_fragment)

def relative_to(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.relative_to(base)
URL('path/index.html')
>>> base.relative_to(target)
URL('..')
"""

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 from_parts("", "", path, "", "")

def join(self, url: "URL") -> "URL":
"""Join URLs

Expand Down
Loading