diff --git a/CHANGES/3951.feature.rst b/CHANGES/3951.feature.rst new file mode 100644 index 00000000000..ba127c20a83 --- /dev/null +++ b/CHANGES/3951.feature.rst @@ -0,0 +1 @@ +Added :py:attr:`~aiohttp.CookieJar.cookies` and :py:attr:`~aiohttp.CookieJar.host_only_cookies` read-only properties to :py:class:`~aiohttp.CookieJar` exposing the stored cookies with their full attributes -- by :user:`Br1an67`. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 487691f688f..819d1b3818e 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -68,6 +68,7 @@ Bob Haddleton Boris Feld Borys Vorona Boyi Chen +Br1an67 Brett Cannon Brett Higgins Brian Bouterse diff --git a/aiohttp/abc.py b/aiohttp/abc.py index bd448ed8abd..b9aaa35502a 100644 --- a/aiohttp/abc.py +++ b/aiohttp/abc.py @@ -2,7 +2,8 @@ import socket from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Generator, Iterable, Sequence, Sized -from http.cookies import BaseCookie, Morsel +from http.cookies import BaseCookie, Morsel, SimpleCookie +from types import MappingProxyType from typing import TYPE_CHECKING, Any, TypedDict from multidict import CIMultiDict @@ -158,6 +159,16 @@ class AbstractCookieJar(Sized, Iterable[Morsel[str]]): def quote_cookie(self) -> bool: """Return True if cookies should be quoted.""" + @property + @abstractmethod + def cookies(self) -> MappingProxyType[tuple[str, str], SimpleCookie]: + """Return the cookies stored in this jar.""" + + @property + @abstractmethod + def host_only_cookies(self) -> frozenset[tuple[str, str]]: + """Return the host-only cookies stored in this jar.""" + @abstractmethod def clear(self, predicate: ClearCookiePredicate | None = None) -> None: """Clear all cookies if no predicate is passed.""" diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py index 3ba7de6869b..4fc797ae745 100644 --- a/aiohttp/cookiejar.py +++ b/aiohttp/cookiejar.py @@ -13,6 +13,7 @@ from collections import defaultdict from collections.abc import Iterable, Iterator, Mapping from http.cookies import BaseCookie, Morsel, SimpleCookie +from types import MappingProxyType from typing import Union from yarl import URL @@ -147,6 +148,16 @@ def __init__( def quote_cookie(self) -> bool: return self._quote_cookie + @property + def cookies(self) -> MappingProxyType[tuple[str, str], SimpleCookie]: + """Return the cookies stored in this jar.""" + return MappingProxyType(self._cookies) + + @property + def host_only_cookies(self) -> frozenset[tuple[str, str]]: + """Return the host-only cookies stored in this jar.""" + return frozenset(self._host_only_cookies) + def save(self, file_path: PathLike) -> None: """Save cookies to a file using JSON format. @@ -598,6 +609,16 @@ def __len__(self) -> int: def quote_cookie(self) -> bool: return True + @property + def cookies(self) -> MappingProxyType[tuple[str, str], SimpleCookie]: + """Return an empty mapping.""" + return MappingProxyType({}) + + @property + def host_only_cookies(self) -> frozenset[tuple[str, str]]: + """Return an empty frozenset.""" + return frozenset() + def clear(self, predicate: ClearCookiePredicate | None = None) -> None: pass diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 033dfb53ca7..527b40bff9f 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -2537,6 +2537,21 @@ Utilities .. versionadded:: 4.0 + .. attribute:: cookies + + A read-only view of the jar's cookies as a + :class:`~types.MappingProxyType` mapping ``(domain, path)`` tuples + to :class:`~http.cookies.SimpleCookie` instances. + + .. versionadded:: 4.0 + + .. attribute:: host_only_cookies + + A :class:`frozenset` of ``(domain, name)`` tuples indicating which + cookies are host-only (not sent to subdomains). + + .. versionadded:: 4.0 + .. class:: DummyCookieJar(*, loop=None) :canonical: aiohttp.cookiejar.DummyCookieJar diff --git a/tests/test_client_session.py b/tests/test_client_session.py index e40001bb307..fca62353402 100644 --- a/tests/test_client_session.py +++ b/tests/test_client_session.py @@ -8,7 +8,7 @@ from collections import deque from collections.abc import Awaitable, Callable, Iterator from http.cookies import BaseCookie, SimpleCookie -from types import SimpleNamespace +from types import MappingProxyType, SimpleNamespace from typing import Any, NoReturn, TypedDict, cast from unittest import mock from uuid import uuid4 @@ -779,6 +779,14 @@ def __init__(self) -> None: def quote_cookie(self) -> bool: return True + @property + def cookies(self) -> MappingProxyType[tuple[str, str], SimpleCookie]: + return MappingProxyType({}) + + @property + def host_only_cookies(self) -> frozenset[tuple[str, str]]: + return frozenset() + def clear(self, predicate: abc.ClearCookiePredicate | None = None) -> None: self._clear_mock(predicate) @@ -800,6 +808,8 @@ def __iter__(self) -> Iterator[Any]: jar = MockCookieJar() assert jar.quote_cookie is True + assert jar.cookies == MappingProxyType({}) + assert jar.host_only_cookies == frozenset() assert len(jar) == 0 assert list(jar) == [] jar.clear() diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py index 24c1f73284c..bcfc55da197 100644 --- a/tests/test_cookiejar.py +++ b/tests/test_cookiejar.py @@ -7,6 +7,7 @@ from http.cookies import BaseCookie, Morsel, SimpleCookie from operator import not_ from pathlib import Path +from types import MappingProxyType from unittest import mock import pytest @@ -780,6 +781,54 @@ async def test_dummy_cookie_jar() -> None: dummy_jar.clear() +async def test_dummy_cookie_jar_cookies_property() -> None: + dummy_jar = DummyCookieJar() + assert dict(dummy_jar.cookies) == {} + assert dummy_jar.host_only_cookies == frozenset() + + +async def test_cookie_jar_cookies_property() -> None: + jar = CookieJar() + cookie = SimpleCookie( + "shared-cookie=first; domain-cookie=second; Domain=example.com; Path=/; " + ) + jar.update_cookies(cookie, URL("http://example.com/")) + + cookies = jar.cookies + # Should be a read-only view + assert isinstance(cookies, MappingProxyType) + # Should contain the stored cookies with their full attributes + found_names = {name for simple_cookie in cookies.values() for name in simple_cookie} + assert "shared-cookie" in found_names + assert "domain-cookie" in found_names + # Verify that domain attribute is preserved + for key, simple_cookie in cookies.items(): + for name, morsel in simple_cookie.items(): + if name == "domain-cookie": + assert morsel["domain"] == "example.com" + assert morsel["path"] == "/" + + +async def test_cookie_jar_host_only_cookies_property() -> None: + jar = CookieJar() + # Cookies without an explicit Domain attribute are host-only + cookie = SimpleCookie("hostonly=value;") + jar.update_cookies(cookie, URL("http://example.com/")) + + host_only = jar.host_only_cookies + assert isinstance(host_only, frozenset) + assert ("example.com", "hostonly") in host_only + + +async def test_cookie_jar_cookies_property_immutable() -> None: + jar = CookieJar() + cookie = SimpleCookie("foo=bar;") + jar.update_cookies(cookie, URL("http://example.com/")) + cookies = jar.cookies + with pytest.raises(TypeError): + cookies[("new", "key")] = SimpleCookie() # type: ignore[index] + + async def test_loose_cookies_types() -> None: jar = CookieJar()