Repository: aio-libs/yarl

Issue:
Add Support for Relative Path Calculation
### Is your feature request related to a problem?

_No response_

### Describe the solution you'd like

#### Current Behavior
The `yarl` library provides an intuitive API for handling URL parsing and construction. However, it lacks an operator for directly calculating the relative path between two URLs.

#### Proposed Feature
Introduce support for using the subtraction operator (`-`) to calculate the relative path between two `URL` objects. This would simplify the syntax for relative path calculation, making it more intuitive. The operator could calculate the difference between two URLs' paths and return the relative path.

##### Example Usage
```python
from yarl import URL

url1 = URL('http://example.com/docs/')
url2 = URL('http://example.com/docs/a.json')

relative_path = url1 - url2  # Proposed feature

print(relative_path)  # Expected output: 'a.json'
```

### Describe alternatives you've considered

This feature would simplify URL handling in situations where relative paths are frequently needed and would make yarl even more developer-friendly. It would be especially useful in web applications and API development.

Thank you for considering this feature request!

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct

Discussion:
bdraco: Looks like the tests aren't passing in the CI
bdraco: Sorry about the conflicts. I was doing a bit of housekeeping to better organize internals. I think I've worked them out correctly.
oleksbabieiev: I have also noticed that when using `os.path.relpath()` with absolute and relative paths (e.g. `dir` and `/`), the program first gets the current working directory (essentially doing `os.getcwd()`). Otherwise, it is obviously impossible to calculate the path between the given directories. This behavior is undesirable, so [I have disallowed it](https://github.com/aio-libs/yarl/pull/1340/commits/20060668076cf662fc416030d2d1f35a8bbeea48).
bdraco: The windows wheel build is failing the tests

You probably want to use PosixPurePath instead so windows doesn’t try to use windows path separators 
codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/aio-libs/yarl/branches/oleksbabieiev:relpath)

### Merging #1340 will **improve performances by 8.38%**

<sub>Comparing <code>oleksbabieiev:relpath</code> (39060d1) with <code>master</code> (f029a3c)</sub>



### Summary

`⚡ 1` improvements
`✅ 82` untouched benchmarks




### Benchmarks breakdown

|     | Benchmark | `master` | `oleksbabieiev:relpath` | Change |
| --- | --------- | ----------------------- | ------------------- | ------ |
| ⚡ | `test_update_query_sequence_mapping` | 3.8 ms | 3.5 ms | +8.38% |

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aio-libs/yarl/pull/1340?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 96.02%. Comparing base [(`f029a3c`)](https://app.codecov.io/gh/aio-libs/yarl/commit/f029a3c1f3aa9d4a5ceea871574f26ae77e7b66b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) to head [(`39060d1`)](https://app.codecov.io/gh/aio-libs/yarl/commit/39060d18aee3054e1ab2daeaa39b931f875a63f2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1340      +/-   ##
==========================================
+ Coverage   95.97%   96.02%   +0.04%     
==========================================
  Files          31       31              
  Lines        5645     5708      +63     
  Branches      349      355       +6     
==========================================
+ Hits         5418     5481      +63     
  Misses        201      201              
  Partials       26       26              
```

| [Flag](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | Coverage Δ | |
|---|---|---|
| [CI-GHA](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `96.02% <100.00%> (+0.04%)` | :arrow_up: |
| [MyPy](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `45.74% <73.43%> (+0.32%)` | :arrow_up: |
| [OS-Linux](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.55% <100.00%> (+<0.01%)` | :arrow_up: |
| [OS-Windows](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.61% <100.00%> (+<0.01%)` | :arrow_up: |
| [OS-macOS](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.29% <100.00%> (+<0.01%)` | :arrow_up: |
| [Py-3.10.11](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.27% <100.00%> (+<0.01%)` | :arrow_up: |
| [Py-3.10.15](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.50% <100.00%> (+<0.01%)` | :arrow_up: |
| [Py-3.11.10](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.50% <100.00%> (+<0.01%)` | :arrow_up: |
| [Py-3.11.9](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.27% <100.00%> (+<0.01%)` | :arrow_up: |
| [Py-3.12.7](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.50% <100.00%> (+<0.01%)` | :arrow_up: |
| [Py-3.13.0](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.50% <100.00%> (+<0.01%)` | :arrow_up: |
| [Py-3.9.13](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.23% <100.00%> (+<0.01%)` | :arrow_up: |
| [Py-3.9.20](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.46% <100.00%> (+<0.01%)` | :arrow_up: |
| [Py-pypy7.3.16](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.52% <100.00%> (+<0.01%)` | :arrow_up: |
| [Py-pypy7.3.17](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.54% <100.00%> (+<0.01%)` | :arrow_up: |
| [VM-macos-latest](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.29% <100.00%> (+<0.01%)` | :arrow_up: |
| [VM-ubuntu-latest](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.55% <100.00%> (+<0.01%)` | :arrow_up: |
| [VM-windows-latest](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.61% <100.00%> (+<0.01%)` | :arrow_up: |
| [pytest](https://app.codecov.io/gh/aio-libs/yarl/pull/1340/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.55% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aio-libs/yarl/pull/1340?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).


Patch:
diff --git a/CHANGES/1340.feature.rst b/CHANGES/1340.feature.rst
new file mode 100644
index 0000000..c879569
--- /dev/null
+++ b/CHANGES/1340.feature.rst
@@ -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`.
diff --git a/docs/api.rst b/docs/api.rst
index 3c12373..c6b5b58 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -974,6 +974,21 @@ The path is encoded if needed.
          >>> base.join(URL('//python.org/page.html'))
          URL('http://python.org/page.html')
 
+The subtraction (``-``) operator creates a new URL with
+a relative *path* to the target URL from the given base URL.
+*scheme*, *user*, *password*, *host*, *port*, *query* and *fragment* are removed.
+
+.. method:: URL.__sub__(url)
+
+   Returns a new URL with a relative *path* between two other URL objects.
+
+   .. doctest::
+
+      >>> target = URL('http://example.com/path/index.html')
+      >>> base = URL('http://example.com/')
+      >>> target - base
+      URL('path/index.html')
+
 Human readable representation
 -----------------------------
 
diff --git a/yarl/_path.py b/yarl/_path.py
index c22f0b4..704f872 100644
--- a/yarl/_path.py
+++ b/yarl/_path.py
@@ -2,6 +2,7 @@
 
 from collections.abc import Sequence
 from contextlib import suppress
+from pathlib import PurePosixPath
 
 
 def normalize_path_segments(segments: Sequence[str]) -> list[str]:
@@ -39,3 +40,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 not base.endswith("/"):
+        base_path = base_path.parent
+
+    for step, path in enumerate([base_path] + list(base_path.parents)):
+        if target_path.is_relative_to(path):
+            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:]))
diff --git a/yarl/_url.py b/yarl/_url.py
index ff6ebb0..5574a01 100644
--- a/yarl/_url.py
+++ b/yarl/_url.py
@@ -12,7 +12,7 @@ from multidict import MultiDict, MultiDictProxy
 from propcache.api import under_cached_property as cached_property
 
 from ._parse import USES_AUTHORITY, make_netloc, split_netloc, 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,
@@ -476,6 +476,34 @@ class URL:
             return NotImplemented
         return self._make_child((str(name),))
 
+    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.
+        The new relative URL has only path:
+        scheme, user, password, host, port, query and fragment are removed.
+
+        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 = self._val
+        base = 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_tup(("", "", path, "", ""))
+
     def __mod__(self, query: Query) -> "URL":
         return self.update_query(query)
 


Test patch:
diff --git a/tests/test_url.py b/tests/test_url.py
index 5546ca0..8973b1f 100644
--- a/tests/test_url.py
+++ b/tests/test_url.py
@@ -61,6 +61,53 @@ 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/path/to", "http://example.com/spam/", "../path/to"),
+        ("http://example.com/path", "http://example.com/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"),
+        ("path/to", "spam", "path/to"),
+        ("..", ".", ".."),
+        (".", "..", "."),
+    ],
+)
+def test_sub(target: str, base: str, expected: str):
+    assert URL(target) - URL(base) == URL(expected)
+
+
+def test_sub_with_different_schemes():
+    expected_error_msg = "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 = "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 = "'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 = "'..' 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)