Repository: aio-libs/aiohttp

Issue:
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).


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')
```
My guts feel that `filter` can be a secondary solution, but people need something easier to understand in the first place.
BTW, are filtered records removed from the jar or kept in?

Maybe I'm wrong and good documentation with a couple of examples: one for a site with subdomains and another for something else -- can teach users comprehensively.
I was thinking something along the lines of:

```python
def clear(self,
          domain: Optional[str]=None,
          filter: Optional[Callable[[SimpleCookie], bool]]=None) -> None:
    """
    Clears the cookies from this jar.

    If the ``domain`` parameter is specified, only the cookies belonging
    to that specific domain will be cleared.

    If the ``filter`` parameter is specified, only those cookies for which
    the filter returns `True` will be cleared. If any of the calls to the
    filter fail, no changes will be made to the jar.

    A `ValueError` is raised if both ``domain`` and ``filter`` are
    specified at the same time.
    """
```

People don't need to use `filter` if they don't need to, but when they do need it, it will be useful to have.
The design is viable. 
Small architecture note: you have mutually exclusive `domain` and `filter` parameters. We have eighter
1. Prevent passing both `domain` and `filter` into the same call; mention in docs that params are mutually exclusive.
2. Split `clear()` into three methods with separate names :)
We could technically make both parameters work, but because I realized it would probably be more confusing (is it clearing both domain and filter, or filtering only those in domain?), I edited my comment to mention that they are exclusive in the documentation. If you're happy with the proposed function signature I can go with that.
Please use separate methods with the following signatures:
```
def clear(self, predicate: Optional[Callable[[SimpleCookie], bool]]=None) -> None:
    # drop cookies for which predicate(cookie) returns True,
    # remove everything is the predicate is omitted.

def clear_domain(self, domain: str) -> None:
    # drop cookies belonging to the domain
    # subdomains are cleared as well
```
`clear_domain()` should be implemented as a simple shortcut which calls `clear()` with a properly constructed predicate.
> `clear_domain()` should be implemented as a simple shortcut which calls `clear()` with a properly constructed predicate.

Are you sure? The jar maps `{domain: {name: cookie}}`, so not using the predicate would be the most efficient way to do it (just drop the entire key for a domain), unless I'm missing something. Of course perhaps we shouldn't worry about that bit of performance, but it's better to double check.
I don't think that the method is a performance bottleneck.  For `_host_only_cookies` you need to iterate over the whole container (or change the data structure) anyway.

Resolving patch:
diff --git a/aiohttp/abc.py b/aiohttp/abc.py
--- 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
--- 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:


Test patch:
diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py
--- 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)