Repository: aio-libs/aiohttp
Pull request: Cookie jar delete specific cookie
<!-- Thank you for your contribution! -->

## What do these changes do?

Add a predicate to AbstractCookieJar.clear
Add AbstractCookieJar.clear_domain method

## Are there changes in behavior for the user?

Yes

## Related issue number

Fixes #4942 

## Checklist

- [x] I think the code is well written
- [x] Unit tests for the changes exist
- [x] Documentation reflects the changes
- [x] If you provide code modification, please add yourself to `CONTRIBUTORS.txt`
  * The format is &lt;Name&gt; &lt;Surname&gt;.
  * Please keep alphabetical order, the file is sorted by names.
- [x] Add a new news fragment into the `CHANGES` folder
  * name it `<issue_id>.<type>` for example (588.bugfix)
  * if you don't have an `issue_id` change it to the pr id after creating the pr
  * ensure type is one of the following:
    * `.feature`: Signifying a new feature.
    * `.bugfix`: Signifying a bug fix.
    * `.doc`: Signifying a documentation improvement.
    * `.removal`: Signifying a deprecation or removal of public API.
    * `.misc`: A ticket has been closed, but it is not of interest to users.
  * Make sure to use full sentences with correct case and punctuation, for example: "Fix issue with non-ascii contents in doctest text files."


Problem statement:
Method to delete a specific set of cookies
Currently if one wants to delete some cookies, the way to do it is not really obvious:

```python
new_cookies = []

for c in session.cookie_jar:
    if check(c):
        c['max-age'] = -1
        new_cookies.append((c.key, c))

session.cookie_jar.update_cookies(new_cookies)
```

It would be nice to have a method to do this cleanly.

Use case: one may want to clear the cookies from a session before trying again the same operation but not wishing to recreate the session (or being unable to do so).

Hints from issue discussion:
Hi @Lonami there is `clear` method in  https://github.com/aio-libs/aiohttp/blob/master/aiohttp/cookiejar.py#L71 . Is this something you missed? You can directly call `session.cookie_jar.clear()` .
I don't recall if I saw it, but that seems to delete *all* the cookies of the session. I was looking for something that deletes only *some* of the cookies. I'd expect a method where I can provide a list of cookies to delete.
I have updated the original post and title to clarify.
This makes sense now. @webknjaz can I start working on it?
Someone is working on the issue? cc @webknjaz 
@WolframAlph @myusko feel free to implement this.
CookieJar is for client cookies. 
The cookie lifespan is controlled by a server usually, the jar is just client-side storage.
I'm curious what is your use-case?
I am using a single `ClientSession` to access multiple different sites, and at times I need to clear a specific cookie to have the specific site send me a fresh value while not altering the cookies in the rest of sites. Much like Firefox lets you clear the "site data", including cookies, of just one site and not the entire browser.
Okay, I can imagine the `clear_domain()` method that deletes all cookies for the passed domain AND all subdomains regardless of cookie path and expiration dates.
Another alternative is extending the `clear()` method to accept an optional `domain` argument, delete all cookies if `domain is None` (the default behavior).

I unlikely can find time for the feature soon but open to review/merge a PR if somebody wants to propose it.
What about a `filter` parameter that accepts a callable with the cookie as input parameter and the boolean it returns determines whether to clear it or not? This gives the largest flexibility, and would also support the "delete domain" use case quite easily:

```python
session.cookie_jar.clear(filter=lambda cookie: cookie.domain == 'example.com')
```

Commit message: Update 4942.feature

Patch to review:
diff --git a/CHANGES/4942.feature b/CHANGES/4942.feature
new file mode 100644
index 00000000000..b64c4ddd088
--- /dev/null
+++ b/CHANGES/4942.feature
@@ -0,0 +1,2 @@
+Add predicate to ``AbstractCookieJar.clear``.
+Add ``AbstractCookieJar.clear_domain`` to clean all domain and subdomains cookies only.
diff --git a/aiohttp/abc.py b/aiohttp/abc.py
index ea4ae13f79e..08a3fe02270 100644
--- a/aiohttp/abc.py
+++ b/aiohttp/abc.py
@@ -133,12 +133,19 @@ async def close(self) -> None:
     IterableBase = Iterable
 
 
+ClearCookiePredicate = Callable[["Morsel[str]"], bool]
+
+
 class AbstractCookieJar(Sized, IterableBase):
     """Abstract Cookie Jar."""
 
     @abstractmethod
-    def clear(self) -> None:
-        """Clear all cookies."""
+    def clear(self, predicate: Optional[ClearCookiePredicate] = None) -> None:
+        """Clear all cookies if no predicate is passed."""
+
+    @abstractmethod
+    def clear_domain(self, domain: str) -> None:
+        """Clear all cookies for domain and all subdomains."""
 
     @abstractmethod
     def update_cookies(self, cookies: LooseCookies, response_url: URL = URL()) -> None:
diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py
index 3c8efa1a04c..2fa6480649c 100644
--- a/aiohttp/cookiejar.py
+++ b/aiohttp/cookiejar.py
@@ -21,7 +21,7 @@
 
 from yarl import URL
 
-from .abc import AbstractCookieJar
+from .abc import AbstractCookieJar, ClearCookiePredicate
 from .helpers import get_running_loop, is_ip_address, next_whole_second
 from .typedefs import LooseCookies, PathLike
 
@@ -81,11 +81,41 @@ def load(self, file_path: PathLike) -> None:
         with file_path.open(mode="rb") as f:
             self._cookies = pickle.load(f)
 
-    def clear(self) -> None:
-        self._cookies.clear()
-        self._host_only_cookies.clear()
-        self._next_expiration = next_whole_second()
-        self._expirations.clear()
+    def clear(self, predicate: Optional[ClearCookiePredicate] = None) -> None:
+        if predicate is None:
+            self._next_expiration = next_whole_second()
+            self._cookies.clear()
+            self._host_only_cookies.clear()
+            self._expirations.clear()
+            return
+
+        to_del = []
+        now = datetime.datetime.now(datetime.timezone.utc)
+        for domain, cookie in self._cookies.items():
+            for name, morsel in cookie.items():
+                key = (domain, name)
+                if (
+                    key in self._expirations and self._expirations[key] <= now
+                ) or predicate(morsel):
+                    to_del.append(key)
+
+        for domain, name in to_del:
+            key = (domain, name)
+            self._host_only_cookies.discard(key)
+            if key in self._expirations:
+                del self._expirations[(domain, name)]
+            self._cookies[domain].pop(name, None)
+
+        next_expiration = min(self._expirations.values(), default=self._max_time)
+        try:
+            self._next_expiration = next_expiration.replace(
+                microsecond=0
+            ) + datetime.timedelta(seconds=1)
+        except OverflowError:
+            self._next_expiration = self._max_time
+
+    def clear_domain(self, domain: str) -> None:
+        self.clear(lambda x: self._is_domain_match(domain, x["domain"]))
 
     def __iter__(self) -> "Iterator[Morsel[str]]":
         self._do_expiration()
@@ -96,31 +126,7 @@ def __len__(self) -> int:
         return sum(1 for i in self)
 
     def _do_expiration(self) -> None:
-        now = datetime.datetime.now(datetime.timezone.utc)
-        if self._next_expiration > now:
-            return
-        if not self._expirations:
-            return
-        next_expiration = self._max_time
-        to_del = []
-        cookies = self._cookies
-        expirations = self._expirations
-        for (domain, name), when in expirations.items():
-            if when <= now:
-                cookies[domain].pop(name, None)
-                to_del.append((domain, name))
-                self._host_only_cookies.discard((domain, name))
-            else:
-                next_expiration = min(next_expiration, when)
-        for key in to_del:
-            del expirations[key]
-
-        try:
-            self._next_expiration = next_expiration.replace(
-                microsecond=0
-            ) + datetime.timedelta(seconds=1)
-        except OverflowError:
-            self._next_expiration = self._max_time
+        self.clear(lambda x: False)
 
     def _expire_cookie(self, when: datetime.datetime, domain: str, name: str) -> None:
         self._next_expiration = min(self._next_expiration, when)
@@ -370,7 +376,10 @@ def __iter__(self) -> "Iterator[Morsel[str]]":
     def __len__(self) -> int:
         return 0
 
-    def clear(self) -> None:
+    def clear(self, predicate: Optional[ClearCookiePredicate] = None) -> None:
+        pass
+
+    def clear_domain(self, domain: str) -> None:
         pass
 
     def update_cookies(self, cookies: LooseCookies, response_url: URL = URL()) -> None:
diff --git a/docs/abc.rst b/docs/abc.rst
index c44e2e7120d..a5cae4be91c 100644
--- a/docs/abc.rst
+++ b/docs/abc.rst
@@ -145,6 +145,22 @@ Abstract Cookie Jar
       :return: :class:`http.cookies.SimpleCookie` with filtered
          cookies for given URL.
 
+   .. method:: clear(predicate=None)
+
+      Removes all cookies from the jar if the predicate is ``None``. Otherwise remove only those :class:`~http.cookies.Morsel` that ``predicate(morsel)`` returns ``True``.
+
+      :param predicate: callable that gets :class:`~http.cookies.Morsel` as a parameter and returns ``True`` if this :class:`~http.cookies.Morsel` must be deleted from the jar.
+
+          .. versionadded:: 4.0
+
+   .. method:: clear_domain(domain)
+
+      Remove all cookies from the jar that belongs to the specified domain or its subdomains.
+
+      :param str domain: domain for which cookies must be deleted from the jar.
+
+      .. versionadded:: 4.0
+
 Abstract Abstract Access Logger
 -------------------------------
 
diff --git a/docs/client_reference.rst b/docs/client_reference.rst
index 407006fda17..1b82939bbdd 100644
--- a/docs/client_reference.rst
+++ b/docs/client_reference.rst
@@ -1842,6 +1842,22 @@ CookieJar
       :param file_path: Path to file from where cookies will be
            imported, :class:`str` or :class:`pathlib.Path` instance.
 
+   .. method:: clear(predicate=None)
+
+      Removes all cookies from the jar if the predicate is ``None``. Otherwise remove only those :class:`~http.cookies.Morsel` that ``predicate(morsel)`` returns ``True``.
+
+      :param predicate: callable that gets :class:`~http.cookies.Morsel` as a parameter and returns ``True`` if this :class:`~http.cookies.Morsel` must be deleted from the jar.
+
+          .. versionadded:: 4.0
+
+   .. method:: clear_domain(domain)
+
+      Remove all cookies from the jar that belongs to the specified domain or its subdomains.
+
+      :param str domain: domain for which cookies must be deleted from the jar.
+
+      .. versionadded:: 4.0
+
 
 .. class:: DummyCookieJar(*, loop=None)
 
diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py
index 4b9eeee547b..0a5f85135f7 100644
--- a/tests/test_cookiejar.py
+++ b/tests/test_cookiejar.py
@@ -698,3 +698,48 @@ async def test_loose_cookies_types() -> None:
 
     for loose_cookies_type in accepted_types:
         jar.update_cookies(cookies=loose_cookies_type)
+
+
+async def test_cookie_jar_clear_all():
+    sut = CookieJar()
+    cookie = SimpleCookie()
+    cookie["foo"] = "bar"
+    sut.update_cookies(cookie)
+
+    sut.clear()
+    assert len(sut) == 0
+
+
+async def test_cookie_jar_clear_expired():
+    sut = CookieJar()
+
+    cookie = SimpleCookie()
+
+    cookie["foo"] = "bar"
+    cookie["foo"]["expires"] = "Tue, 1 Jan 1990 12:00:00 GMT"
+
+    with freeze_time("1980-01-01"):
+        sut.update_cookies(cookie)
+
+    sut.clear(lambda x: False)
+    with freeze_time("1980-01-01"):
+        assert len(sut) == 0
+
+
+async def test_cookie_jar_clear_domain():
+    sut = CookieJar()
+    cookie = SimpleCookie()
+    cookie["foo"] = "bar"
+    cookie["domain_cookie"] = "value"
+    cookie["domain_cookie"]["domain"] = "example.com"
+    cookie["subdomain_cookie"] = "value"
+    cookie["subdomain_cookie"]["domain"] = "test.example.com"
+    sut.update_cookies(cookie)
+
+    sut.clear_domain("example.com")
+    iterator = iter(sut)
+    morsel = next(iterator)
+    assert morsel.key == "foo"
+    assert morsel.value == "bar"
+    with pytest.raises(StopIteration):
+        next(iterator)


Code review comments:
diff_hunk: @@ -145,6 +145,22 @@ Abstract Cookie Jar
       :return: :class:`http.cookies.SimpleCookie` with filtered
          cookies for given URL.
 
+   .. method:: clear(predicate=None)
+
+      Removes all cookies from the jar if the predicate is ``None``. Otherwise remove only those :class:`~http.cookies.Morsel` that ``predicate(morsel)`` returns ``True``.
+
+      :param predicate: callable that gets :class:`~http.cookies.Morsel` as a parameter and returns ``True`` if this :class:`~http.cookies.Morsel` must be deleted from the jar.
+
+          .. versionadded:: 4.0
+
+   .. method:: clear_domain(domain)
+
+      Remove all cookies from the jar that belongs to the specified domain or its subdomains.
+
+      :param str domain: domain for which cookies must be deleted from the jar.
+
+      .. versionadded:: 4.0
path: docs/abc.rst
text: @user1:
```suggestion
      .. versionadded:: 3.8
```