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 <dev@disqualifier.me>
This commit is contained in:
2026-07-02 16:42:30 -04:00
parent b8cd184c64
commit e1ab5d38a0
3 changed files with 96 additions and 15 deletions
+26 -3
View File
@@ -11,18 +11,18 @@ and swap the HTTP client while inheriting everything else.
`requirements.txt`: `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: Direct:
```bash ```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). 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 ## Usage
@@ -132,6 +132,29 @@ Two changes can't be shimmed without re-introducing the bugs they fix:
## Changelog ## 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 ### v0.1.2
- Pinned `commons` to v0.2.1 (retry `attempts` floor fix). - Pinned `commons` to v0.2.1 (retry `attempts` floor fix).
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "aioweb" 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." description = "Async HTTP session wrapper over aiohttp — proxies, header overwrites, retries, previews. Config-free, installable."
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+69 -11
View File
@@ -12,7 +12,10 @@ subclass and override just that one method, inheriting everything else.
if resp: # FailureResponse is falsy if resp: # FailureResponse is falsy
data = resp.json() 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 sessions must be closed explicitly (async with, or await s.close()); there is no
__del__ auto-close (that pattern is unsafe for async resources). __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 asyncio
import logging import logging
import warnings import warnings
from http.cookies import SimpleCookie
import aiohttp import aiohttp
from yarl import URL from yarl import URL
@@ -86,7 +90,27 @@ class ExtendedSession:
self.proxies = proxies or {} self.proxies = proxies or {}
# track our own default headers instead of touching aiohttp privates # track our own default headers instead of touching aiohttp privates
self._default_headers = dict(headers or {}) 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): def _create_session(self, headers, timeout, **kwargs):
"""create the backend HTTP session — override to use a different client """create the backend HTTP session — override to use a different client
@@ -217,13 +241,36 @@ class ExtendedSession:
# cookies # cookies
def get_cookies(self): def get_cookies(self):
"""cookies stored in the session jar""" """all cookies stored in the session jar, regardless of domain binding
return self.session.cookie_jar.filter_cookies()
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="/"): def set_cookie(self, name, value, domain=None, path="/"):
"""set a cookie in the session jar""" """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) 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): def clear_cookies(self):
"""clear the session cookie jar""" """clear the session cookie jar"""
@@ -394,12 +441,23 @@ class ExtendedSession:
# lifecycle # lifecycle
async def close(self): async def close(self):
"""close the backend session — override if the backend's close differs""" """close the backend session — override if the backend's close differs
await self.session.close()
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: def _is_closed(self) -> bool:
"""whether the backend session is closed — override for non-aiohttp backends""" """whether the backend session is closed — override for non-aiohttp backends
return self.session.closed
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): async def __aenter__(self):
return self return self