diff --git a/README.md b/README.md index d389b43..d938e13 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.9 +aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.10 ``` Direct: ```bash -pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.9" +pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.10" ``` Requires `aiohttp` and `yarl` (pulled transitively). -Drop the `@v0.1.9` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.10` suffix from the line above to install the latest unpinned. ## Usage @@ -144,6 +144,11 @@ Two changes can't be shimmed without re-introducing the bugs they fix: ## Changelog +### v0.1.10 + +- Docs-only pass: compressed module/method docstrings and comments that restated + README/CLAUDE prose, replaced em-dashes with hyphens. No behavior change. + ### v0.1.9 - **`_get_proxy()`/`request()` proxy resolution now checks `is None`, not diff --git a/pyproject.toml b/pyproject.toml index e4d484c..de71344 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aioweb" -version = "0.1.9" +version = "0.1.10" 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/__init__.py b/src/aioweb/__init__.py index 557892d..0d5eb2a 100644 --- a/src/aioweb/__init__.py +++ b/src/aioweb/__init__.py @@ -17,17 +17,11 @@ __all__ = [ async def request_retries(session, method, url, **kwargs): - """back-compat wrapper for session.request_with_retries(...) - - prefer calling session.request_with_retries(...) directly. - """ + """back-compat wrapper for session.request_with_retries(...); prefer calling that directly""" return await session.request_with_retries(method, url, **kwargs) async def test_proxies(session, url="https://api.ipify.org?format=json"): - """fetch the public IP via the session (verifies proxy config) - - url defaults to ipify; pass another IP-echo endpoint to point elsewhere. - """ + """fetch the public IP via the session (verifies proxy config); url defaults to ipify""" response = await session.request("GET", url) return response.json() diff --git a/src/aioweb/preview.py b/src/aioweb/preview.py index 8df7290..810b496 100644 --- a/src/aioweb/preview.py +++ b/src/aioweb/preview.py @@ -1,5 +1,5 @@ """ -request preview for aioweb — format or export a request without sending it +request preview for aioweb - format or export a request without sending it """ import json as _json @@ -28,13 +28,7 @@ class RequestPreview: return "\n".join(f"{key}: {value}" for key, value in self.details.items()) def as_curl(self): - """equivalent cURL command for the request - - every interpolated value is shlex.quote'd (non-injectable even with quotes/ - spaces/metacharacters). `params` is merged into the url's query string and - `timeout` rendered as `--max-time`, so the command matches what request() - actually sends. - """ + """equivalent cURL command for the request (values shlex.quote'd; matches what request() sends)""" parts = [f"curl -X {shlex.quote(self.details['method'])}"] for header, value in (self.details["headers"] or {}).items(): parts.append(f"-H {shlex.quote(f'{header}: {value}')}") diff --git a/src/aioweb/responses.py b/src/aioweb/responses.py index 2f6e124..33b3d9d 100644 --- a/src/aioweb/responses.py +++ b/src/aioweb/responses.py @@ -1,8 +1,7 @@ """ -backend-agnostic response objects for aioweb — Response is built from primitives -(status, headers, content, url, history), not a raw aiohttp object, so any backend can -produce one; FailureResponse mirrors the same surface (every status/predicate a -property on both) so callers can branch uniformly. +backend-agnostic response objects for aioweb - Response and FailureResponse share +the same surface (every status/predicate a property on both) so callers can branch +uniformly regardless of which one they got. """ import json as _json @@ -32,32 +31,26 @@ class Response: @property def status_code(self) -> int: - """HTTP status code""" return self._status_code @property def headers(self): - """response headers""" return self._headers @property def url(self) -> str: - """final URL after redirects""" return self._url @property def reason(self): - """reason phrase for the status code""" return self._reason @property def cookies(self): - """cookies set in the response""" return self._cookies @property def history(self): - """redirect history (list of (status, url) tuples)""" return self._history @property @@ -67,17 +60,14 @@ class Response: @property def is_redirect(self) -> bool: - """whether the status is a redirect""" return self._status_code in (301, 302, 303, 307, 308) @property def is_success(self) -> bool: - """whether the status indicates success (2xx)""" return 200 <= self._status_code < 300 @property def content(self) -> bytes: - """raw response bytes""" return self._content def text(self, encoding: Optional[str] = None) -> str: @@ -94,7 +84,7 @@ class Response: return _json.loads(self.text()) except (_json.JSONDecodeError, UnicodeDecodeError): # text() decodes the body and can raise UnicodeDecodeError on a non-UTF-8 - # payload — that's a "not valid JSON" outcome, not an error to propagate + # payload - that's a "not valid JSON" outcome, not an error to propagate return None def raise_for_status(self): @@ -173,5 +163,5 @@ class FailureResponse: return f"" -# back-compat alias — the response class was renamed Response +# back-compat alias - the response class was renamed Response aiowebResponse = Response diff --git a/src/aioweb/session.py b/src/aioweb/session.py index 492b80c..ae38c51 100644 --- a/src/aioweb/session.py +++ b/src/aioweb/session.py @@ -1,18 +1,11 @@ """ -async HTTP session wrapper over aiohttp — proxies, header overwrites, ephemeral -headers, domain rewriting, previews, retry/backoff; byte-sending is isolated in -_raw_request() so a subclass can swap backends and inherit everything else. +async HTTP session wrapper over aiohttp - proxies, header overwrites, ephemeral +headers, domain rewriting, previews, retry/backoff. See README for usage and contract. async with ExtendedSession(proxies={"https": "http://..."}) as s: resp = await s.request_with_retries("GET", url) if resp: # FailureResponse is falsy data = resp.json() - -config-free (proxies/headers/timeouts passed at construction or per call). the -backend session is built lazily on first use, not in __init__, so construction is -safe before an event loop is running (e.g. bot.http = ExtendedSession(...)). -sessions must be closed explicitly (async with, or await s.close()) — no __del__ -auto-close (unsafe for async resources). """ import asyncio @@ -36,11 +29,7 @@ def _route_body(data): class _RetryStatus(Exception): - """internal signal: a retryable HTTP status; carries the real Response - - raised inside an attempt so commons.aretry drives the backoff + cap; the caller - catches the final one to return the REAL last response, not a synthetic failure. - """ + """internal signal: a retryable HTTP status; carries the real Response through aretry""" def __init__(self, response): super().__init__(f"retryable status {response.status_code}") @@ -51,7 +40,6 @@ log = logging.getLogger(__name__) DEFAULT_ATTEMPTS = 3 DEFAULT_BACKOFF_BASE = 2.0 -# statuses worth retrying: rate limit + transient server errors RETRY_STATUSES = frozenset({429, 500, 502, 503, 504}) @@ -82,9 +70,7 @@ class ExtendedSession: self._default_headers = dict(headers or {}) self._session_timeout = timeout self._session_kwargs = kwargs - # aiohttp>=3.14 requires a running loop to build ClientSession; build lazily - # (via `session` / _ensure_session()) so construction before the loop starts - # (e.g. bot.http = ExtendedSession(...) in Bot.__init__) doesn't crash + # aiohttp>=3.14 needs a running loop to build ClientSession; built lazily instead self._session = None @property @@ -94,20 +80,18 @@ class ExtendedSession: return self._session def _ensure_session(self): - """build the backend session on first use — idempotent""" + """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 swap HTTP clients + """create the backend HTTP session - override to swap HTTP clients - overwrite/domain/proxy/retry/preview logic never touches the session object - directly (only _raw_request, cookies, close do), so those work unchanged on - any backend. `headers` isn't baked in here — aiohttp would copy it into an - immutable map update_headers/clear_headers can't touch; `_default_headers` - is the mutable layer request()/preview() merge per call instead. + `headers` isn't baked in here - aiohttp would copy it into an immutable map + update_headers/clear_headers can't touch; `_default_headers` is the mutable + layer request()/preview() merge per call instead. """ return aiohttp.ClientSession( timeout=aiohttp.ClientTimeout( @@ -229,7 +213,7 @@ class ExtendedSession: """all cookies 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 — {} for any normal one). + given none, returns only domain-less shared cookies - {} for any normal one). """ return {c.key: c.value for c in self.session.cookie_jar} @@ -237,7 +221,7 @@ class ExtendedSession: """set a cookie in the session jar domain=None stores a truly shared cookie sent to every host. domain='example.com' - (scheme optional, defaulted to http://) scopes it to that host — a bare hostname + (scheme optional, defaulted to http://) scopes it to that host - a bare hostname is normalized into a URL so the jar binds by host instead of silently storing another domain-less shared cookie. `path` is honored via the morsel itself. """ @@ -256,7 +240,7 @@ class ExtendedSession: self.session.cookie_jar.clear() def _cookies_for_url(self, url): - """dict of cookies the backend would send for url — override per backend (used by preview())""" + """dict of cookies the backend would send for url - override per backend (used by preview())""" return {k: v.value for k, v in self.session.cookie_jar.filter_cookies(URL(url)).items()} # ------------------------------------------------------------------------- @@ -266,7 +250,7 @@ class ExtendedSession: """build a RequestPreview for a request without sending it url is domain-rewritten before cookies are resolved, matching the real - request — cookie binding is host-based, so resolving against the pre-rewrite + request - cookie binding is host-based, so resolving against the pre-rewrite host could miss or misattribute cookies. """ url = self._apply_domain_overwrites(url) @@ -328,7 +312,7 @@ class ExtendedSession: timeout; any other aiohttp.ClientError re-raised as-is, not flattened, so callers can branch by type or read subtype attributes like .status/.headers). a native proxy= is never silently clobbered by resolved proxies= (that would - unmask the caller's real IP) — pass only one of them. + unmask the caller's real IP) - pass only one of them. """ resolved_proxy = self._get_proxy(url, kwargs.pop("proxies", None)) if "proxy" in kwargs and kwargs["proxy"] is not None and resolved_proxy is not None: @@ -344,8 +328,8 @@ class ExtendedSession: if isinstance(timeout, (int, float)): kwargs["timeout"] = aiohttp.ClientTimeout(total=timeout) elif timeout is None and "timeout" in kwargs: - # explicit timeout=None would reach aiohttp as ClientTimeout(total=None) — - # infinite, disabling the session default — so drop it instead (matters for + # explicit timeout=None would reach aiohttp as ClientTimeout(total=None) - + # infinite, disabling the session default - so drop it instead (matters for # request_with_retries, whose timeout kwarg defaults to None) del kwargs["timeout"] @@ -359,12 +343,11 @@ class ExtendedSession: log.info("redirect chain: %s", result.redirect_chain) return result except asyncio.TimeoutError as error: - # a bare asyncio.TimeoutError isn't an aiohttp.ClientError subclass — wrap it - # as ServerTimeoutError (both a ClientError AND a TimeoutError) so callers get - # a typed failure and request_with_retries can still label it a timeout + # not an aiohttp.ClientError subclass - wrap as ServerTimeoutError so + # callers get a typed failure and request_with_retries can label it a timeout raise aiohttp.ServerTimeoutError(f"timeout for {url}: {error}") from error except aiohttp.ClientError as error: - # re-raise the original subtype (not flattened) — request_with_retries still + # re-raise the original subtype (not flattened) - request_with_retries still # catches the base aiohttp.ClientError below and is unaffected log.error("client error for %s: %s", url, error) raise @@ -414,8 +397,7 @@ class ExtendedSession: attempts, url, exhausted.response.status_code) return exhausted.response except asyncio.TimeoutError: - # request() wraps timeouts as ServerTimeoutError (ClientError + TimeoutError); - # catch it first so it's labeled a timeout, not a generic client error below + # catch before ClientError so a timeout is labeled as such, not generic log.error("all %d attempts timed out for %s", attempts, url) return FailureResponse(reason="timeout", url=url) except aiohttp.ClientError as error: @@ -429,15 +411,14 @@ class ExtendedSession: # lifecycle async def close(self): - """close the backend session — override if the backend's close differs; no-op if never built""" + """close the backend session - override if the backend's close differs; no-op if never built""" 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 + """whether the backend session is closed - override for non-aiohttp backends - an unbuilt session counts as closed: nothing was opened, nothing to leak, - __del__ should not warn. + an unbuilt session counts as closed: nothing was opened, nothing to leak. """ if self._session is None: return True @@ -450,7 +431,7 @@ class ExtendedSession: await self.close() def __del__(self): - # no async cleanup here — spinning event loops in a finalizer is unsafe; + # no async cleanup here - spinning event loops in a finalizer is unsafe; # just warn so the leak is visible, callers must close explicitly try: closed = self._is_closed()