From e1ab5d38a0ccde85de7fadf941b879e07ff76a9b Mon Sep 17 00:00:00 2001 From: disqualifier Date: Thu, 2 Jul 2026 16:42:30 -0400 Subject: [PATCH] fix: cookie methods actually work; session builds lazily (v0.1.7) get_cookies() called filter_cookies() with no URL and always returned {} for domain-bound cookies; now iterates the jar directly. set_cookie() ignored path and leaked a shared cookie to every host when given a bare domain string; scheme is now normalized and path honored, with domain=None made an intentionally shared cookie instead of a silent localhost-only no-op. ExtendedSession also built its aiohttp.ClientSession synchronously in __init__, which requires a running event loop under aiohttp>=3.14 and crashed the common construct-before-the-loop-starts host pattern. The session now builds lazily on first access, preserving the _create_session subclass override seam used by aioweb_tls. Signed-off-by: disqualifier --- README.md | 29 ++++++++++++++-- pyproject.toml | 2 +- src/aioweb/session.py | 80 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 96 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2c99d10..4e8b393 100644 --- a/README.md +++ b/README.md @@ -11,18 +11,18 @@ and swap the HTTP client while inheriting everything else. `requirements.txt`: ``` -aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.6 +aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.7 ``` Direct: ```bash -pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.6" +pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.7" ``` Requires `aiohttp` and `yarl` (pulled transitively). -Drop the `@v0.1.5` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.7` suffix from the line above to install the latest unpinned. ## Usage @@ -132,6 +132,29 @@ Two changes can't be shimmed without re-introducing the bugs they fix: ## Changelog +### v0.1.7 + +- **`get_cookies()` now returns real cookies.** Previously called `filter_cookies()` + with no URL, which only ever returns domain-less shared cookies — every normal + domain-bound cookie (including ones set by a real `Set-Cookie` response) was + silently omitted. Now iterates the jar directly. +- **`set_cookie()` no longer leaks a shared cookie to every host.** A bare hostname + (`domain="example.com"`) built a schemeless URL, which aiohttp's jar treats as a + domain-less "shared" cookie sent with every request the session makes, including + unrelated hosts. A scheme is now added when missing so the cookie is scoped to + that host. +- **`set_cookie()` now honors `path`** (previously ignored — the cookie always + landed at `path="/"`). `domain=None` is unchanged in meaning but now stores a + truly shared cookie (sent to every host) instead of one silently bound to + `localhost` only, which made `set_cookie(name, value)` (no domain) a silent + no-op for any real request. +- **The backend session is built lazily**, not in `__init__`. Under aiohttp 3.14, + constructing `aiohttp.ClientSession` requires a running event loop; eager + construction crashed the common host pattern of attaching a session before the + loop starts (e.g. `bot.http = ExtendedSession(...)` in `Bot.__init__`). The + session (and any subclass's `_create_session` override) now builds on first + access instead. + ### v0.1.2 - Pinned `commons` to v0.2.1 (retry `attempts` floor fix). diff --git a/pyproject.toml b/pyproject.toml index c0360de..2756eaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aioweb" -version = "0.1.6" +version = "0.1.7" description = "Async HTTP session wrapper over aiohttp — proxies, header overwrites, retries, previews. Config-free, installable." requires-python = ">=3.10" dependencies = [ diff --git a/src/aioweb/session.py b/src/aioweb/session.py index 6c0ee18..d4a2d80 100644 --- a/src/aioweb/session.py +++ b/src/aioweb/session.py @@ -12,7 +12,10 @@ subclass and override just that one method, inheriting everything else. if resp: # FailureResponse is falsy data = resp.json() -config-free: proxies/headers/timeouts are passed at construction or per call. +config-free: proxies/headers/timeouts are passed at construction or per call. the +backend HTTP session is built lazily on first use (request/cookie access/close), not +in __init__, so construction is safe before an event loop is running (e.g. attaching +to a host object at process startup). sessions must be closed explicitly (async with, or await s.close()); there is no __del__ auto-close (that pattern is unsafe for async resources). """ @@ -20,6 +23,7 @@ __del__ auto-close (that pattern is unsafe for async resources). import asyncio import logging import warnings +from http.cookies import SimpleCookie import aiohttp from yarl import URL @@ -86,7 +90,27 @@ class ExtendedSession: self.proxies = proxies or {} # track our own default headers instead of touching aiohttp privates self._default_headers = dict(headers or {}) - self.session = self._create_session(self._default_headers, timeout, **kwargs) + self._session_timeout = timeout + self._session_kwargs = kwargs + # aiohttp.ClientSession (via _create_session) requires a running event loop + # (aiohttp >= 3.14 raises RuntimeError otherwise); building it here would + # break the common host pattern of constructing before the loop starts + # (e.g. bot.http = ExtendedSession(...) in Bot.__init__). build lazily on + # first access instead, via the `session` property / _ensure_session(). + self._session = None + + @property + def session(self): + """the backend session, built lazily on first access (needs a running loop)""" + self._ensure_session() + return self._session + + def _ensure_session(self): + """build the backend session on first use — idempotent""" + if self._session is None: + self._session = self._create_session( + self._default_headers, self._session_timeout, **self._session_kwargs, + ) def _create_session(self, headers, timeout, **kwargs): """create the backend HTTP session — override to use a different client @@ -217,13 +241,36 @@ class ExtendedSession: # cookies def get_cookies(self): - """cookies stored in the session jar""" - return self.session.cookie_jar.filter_cookies() + """all cookies stored in the session jar, regardless of domain binding + + iterates the jar directly rather than filter_cookies() (which needs a url + and, given none, returns only domain-less shared cookies — i.e. {} for any + normal domain-bound cookie). + """ + return {c.key: c.value for c in self.session.cookie_jar} def set_cookie(self, name, value, domain=None, path="/"): - """set a cookie in the session jar""" - response_url = URL(domain or "http://localhost") - self.session.cookie_jar.update_cookies({name: value}, response_url=response_url) + """set a cookie in the session jar + + domain=None (the default) stores a truly shared cookie sent with every + request regardless of host — the jar's own "no response_url" behavior. + pass domain='example.com' (a scheme is optional and defaulted to http://) + to scope the cookie to one host; a bare hostname like 'example.com' is + normalized into a URL so the jar binds it by host instead of silently + storing another domain-less shared cookie (a schemeless domain has no + raw_host, so the jar can't tell it apart from the shared case). + `path` is honored via the morsel itself, since the jar only derives a path + from response_url when the morsel doesn't already carry one. + """ + cookie = SimpleCookie() + cookie[name] = value + if domain is None: + self.session.cookie_jar.update_cookies(cookie) + return + if "://" not in domain: + domain = "http://" + domain + cookie[name]["path"] = path + self.session.cookie_jar.update_cookies(cookie, response_url=URL(domain)) def clear_cookies(self): """clear the session cookie jar""" @@ -394,12 +441,23 @@ class ExtendedSession: # lifecycle async def close(self): - """close the backend session — override if the backend's close differs""" - await self.session.close() + """close the backend session — override if the backend's close differs + + a no-op if the session was never built (lazy construction means a session + that made no request and was never otherwise touched has nothing to close). + """ + if self._session is not None: + await self._session.close() def _is_closed(self) -> bool: - """whether the backend session is closed — override for non-aiohttp backends""" - return self.session.closed + """whether the backend session is closed — override for non-aiohttp backends + + an unbuilt (never-lazily-created) session counts as closed: nothing was + opened, so there is nothing to leak and __del__ should not warn. + """ + if self._session is None: + return True + return self._session.closed async def __aenter__(self): return self