Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,5 @@ _Is there an existing workaround for this issue?_
### Environment

- aiohttp-client-cache version: [e.g. `0.3.0`]
- Python version: [e.g. `3.9`]
- Python version: [e.g. `3.13`]
- Platform: [e.g. Debian 10]
2 changes: 0 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ jobs:
- '3.13'
- '3.12'
- '3.11'
- '3.10'
- '3.9'
services:
httpbin:
image: mccutchen/go-httpbin
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ nox -e test
Or to run tests for a specific python version:

```sh
nox -e test-3.10
nox -e test-3.11
```

To generate a coverage report:
Expand Down
1 change: 1 addition & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Not released

- Dropped Python 3.9 and 3.10 support due to the EOL.
- Fixed cache key collisions between distinct JSON request bodies. Cache keys now use the
configured JSON serializer, including bytes serializers.
- Fixed cache key collisions between request bodies, such as `data=b'false'` and `json=False`, and between request bodies and headers. Existing cache entries won't be reused after upgrading.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ client requests, based on [requests-cache](https://github.com/reclosedev/request

# Quickstart

First, install with pip (python 3.9+ required):
First, install with pip (python 3.11+ required):

```bash
pip install aiohttp-client-cache[all]
Expand Down
2 changes: 1 addition & 1 deletion aiohttp_client_cache/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from collections.abc import Callable
from inspect import Parameter, signature
from logging import getLogger
from typing import Callable

from aiohttp_client_cache.backends.base import ( # noqa: F401
BaseCache,
Expand Down
11 changes: 4 additions & 7 deletions aiohttp_client_cache/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
import pickle
from abc import ABCMeta, abstractmethod
from collections import UserDict
from collections.abc import AsyncIterable, Awaitable, Iterable
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable
from datetime import datetime
from logging import getLogger
from typing import Any, Callable, Union
from typing import Any

from aiohttp import ClientResponse
from aiohttp.typedefs import StrOrURL
Expand All @@ -17,11 +17,8 @@
from aiohttp_client_cache.cache_keys import create_key
from aiohttp_client_cache.response import AnyResponse, CachedResponse

ResponseOrKey = Union[CachedResponse, bytes, str, None]
_FilterFn = Union[
Callable[[AnyResponse], bool],
Callable[[AnyResponse], Awaitable[bool]],
]
ResponseOrKey = CachedResponse | bytes | str | None
_FilterFn = Callable[[AnyResponse], bool] | Callable[[AnyResponse], Awaitable[bool]]

logger = getLogger(__name__)

Expand Down
39 changes: 13 additions & 26 deletions aiohttp_client_cache/cache_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@

from __future__ import annotations

import sys
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from email.utils import parsedate_to_datetime
from fnmatch import fnmatch
from functools import singledispatch
from itertools import chain
from logging import getLogger
from typing import Any, NoReturn, Union
from typing import Any, NoReturn

from aiohttp import ClientResponse
from aiohttp.typedefs import StrOrURL
Expand All @@ -33,8 +32,8 @@
]
RESPONSE_CACHE_HEADERS = ['Cache-Control', 'ETag', 'Expires', 'Age']

CacheDirective = tuple[str, Union[None, int, bool]]
ExpirationTime = Union[None, int, float, str, datetime, timedelta]
CacheDirective = tuple[str, None | int | bool]
ExpirationTime = None | int | float | str | datetime | timedelta
ExpirationPatterns = dict[str, ExpirationTime]
logger = getLogger(__name__)

Expand Down Expand Up @@ -218,25 +217,13 @@ def compose_refresh_headers(
return conditional_request_supported, refresh_headers


if sys.version_info >= (3, 10):

def parse_http_date(value: str) -> datetime | None:
"""Attempt to parse an HTTP (RFC 5322-compatible) timestamp"""
try:
return parsedate_to_datetime(value)
except ValueError:
logger.debug(f'Failed to parse timestamp: {value}')
return None

else: # pragma: no cover

def parse_http_date(value: str) -> datetime | None:
"""Attempt to parse an HTTP (RFC 5322-compatible) timestamp"""
try:
return parsedate_to_datetime(value)
except (ValueError, TypeError):
logger.debug(f'Failed to parse timestamp: {value}')
return None
def parse_http_date(value: str) -> datetime | None:
"""Attempt to parse an HTTP (RFC 5322-compatible) timestamp"""
try:
return parsedate_to_datetime(value)
except ValueError:
logger.debug(f'Failed to parse timestamp: {value}')
return None


def split_kv_directive(header_value: str) -> CacheDirective:
Expand All @@ -256,7 +243,7 @@ def convert_to_utc_naive(dt: datetime):
datetimes to the same format.
"""
if dt.tzinfo:
dt.astimezone(timezone.utc)
dt.astimezone(UTC)
dt = dt.replace(tzinfo=None)
return dt

Expand All @@ -266,7 +253,7 @@ def convert_to_utc_naive(dt: datetime):
# changes to request matching logic (i.e., new cache keys).
def utcnow() -> datetime:
"""Get the current time in UTC, as a timezone-naive datetime"""
return datetime.now(timezone.utc).replace(tzinfo=None)
return datetime.now(UTC).replace(tzinfo=None)


@singledispatch
Expand Down
4 changes: 2 additions & 2 deletions aiohttp_client_cache/cache_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
from io import IOBase
from json import dumps
from operator import itemgetter
from typing import Any, Union
from typing import Any
from urllib.parse import urlencode

from aiohttp.typedefs import StrOrURL
from multidict import MultiDict
from url_normalize import url_normalize
from yarl import URL

RequestParams = Union[Mapping, Sequence, str]
RequestParams = Mapping | Sequence | str


def create_key(
Expand Down
14 changes: 4 additions & 10 deletions aiohttp_client_cache/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@

import asyncio
import json
import sys
from collections.abc import Mapping
from datetime import datetime
from functools import singledispatch
from http.cookies import SimpleCookie
from logging import getLogger
from typing import Any, Optional, Union
from typing import Any, Self
from unittest.mock import Mock

import attr
Expand All @@ -24,11 +23,6 @@

from aiohttp_client_cache.cache_control import utcnow

if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self

# CachedResponse attributes to not copy directly from ClientResponse
EXCLUDE_ATTRS = {
'_body',
Expand All @@ -52,10 +46,10 @@
'is_expired': False,
}

JsonResponse = Optional[dict[str, Any]]
JsonResponse = dict[str, Any] | None
DictItems = list[tuple[str, str]]
LinkItems = list[tuple[str, DictItems]]
LinkMultiDict = MultiDictProxy[MultiDictProxy[Union[str, URL]]]
LinkMultiDict = MultiDictProxy[MultiDictProxy[str | URL]]

logger = getLogger(__name__)

Expand Down Expand Up @@ -305,7 +299,7 @@ def __init__(self, body: bytes | None = None):
self.feed_eof()


AnyResponse = Union[ClientResponse, CachedResponse]
AnyResponse = ClientResponse | CachedResponse


@singledispatch
Expand Down
26 changes: 5 additions & 21 deletions aiohttp_client_cache/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@

from __future__ import annotations

import sys
import warnings
from asyncio import Lock
from collections.abc import Callable
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, nullcontext
from logging import getLogger
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, Self, cast
from weakref import WeakValueDictionary

from aiohttp import ClientSession
Expand All @@ -20,8 +19,9 @@
from aiohttp_client_cache.signatures import extend_signature

if TYPE_CHECKING:
from typing import Unpack

from aiohttp.client import _RequestContextManager, _RequestOptions
from typing_extensions import Unpack

class _ExpandedRequestOptions(_RequestOptions, total=False):
expire_after: ExpirationTime
Expand All @@ -34,21 +34,6 @@ class _ExpandedRequestOptions(_RequestOptions, total=False):

logger = getLogger(__name__)

if sys.version_info >= (3, 10):
from contextlib import nullcontext
else:
from contextlib import AbstractAsyncContextManager

class nullcontext(AbstractAsyncContextManager):
async def __aexit__(self, *excinfo):
pass


if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self


class CacheMixin(MIXIN_BASE):
"""A mixin class for :py:class:`aiohttp.ClientSession` that adds caching support"""
Expand Down Expand Up @@ -222,8 +207,7 @@ async def delete_expired_responses(self):
"""Remove all expired responses from the cache"""
await self.cache.delete_expired_responses()

# The version check is unnecessary but since aiohttp has done it we're forced into it too.
if sys.version_info >= (3, 11) and TYPE_CHECKING:
if TYPE_CHECKING:

def get(
self,
Expand Down
2 changes: 1 addition & 1 deletion aiohttp_client_cache/signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@

import inspect
import re
from collections.abc import Callable
from logging import getLogger
from typing import Callable

AUTOMETHOD_INIT = '.. automethod:: __init__'
logger = getLogger(__name__)
Expand Down
2 changes: 1 addition & 1 deletion docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ conda install -c conda-forge aiohttp-client-cache

### Requirements

- Requires python 3.9+.
- Requires python 3.11+.
- You may need additional dependencies depending on which backend you want to use. To install with
extra dependencies for all supported {ref}`backends`:
```bash
Expand Down
2 changes: 1 addition & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def install_deps(session):
)


@nox.session(python=['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'])
@nox.session(python=['3.11', '3.12', '3.13', '3.14'])
def test(session):
"""Run tests for a specific python version"""
test_paths = session.posargs or [UNIT_TESTS]
Expand Down
30 changes: 14 additions & 16 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
requires-python = ">=3.9"
requires-python = ">=3.11"
dependencies = [
"aiohttp >=3.8",
"attrs >=21.2",
"itsdangerous >=2.0",
"url-normalize >=2.2",
"typing-extensions >=4 ; python_version<='3.10'", # For Self type addition
]

[project.optional-dependencies]
Expand All @@ -39,7 +38,6 @@ sqlite = ["aiosqlite>=0.20"]
# Development dependencies
dev = [
# For unit + integration tests
"async-timeout >=4.0",
"brotli >=1.0",
"faker >=30.0",
"pytest >=8.4",
Expand All @@ -56,17 +54,17 @@ dev = [

# Documentation dependencies needed for Readthedocs builds
docs = [
"furo >=2025.9; python_version>='3.11'",
"linkify-it-py >=2.0; python_version>='3.11'",
"markdown-it-py >=2.2; python_version>='3.11'",
"myst-parser >=5.0; python_version>='3.11'",
"python-forge >=18.6; python_version>='3.11'",
"sphinx ~=8.2; python_version>='3.11'",
"sphinx-automodapi >=0.18,<0.21; python_version>='3.11'",
"sphinx-autodoc-typehints >=2.4; python_version>='3.11'",
"sphinx-copybutton >=0.5; python_version>='3.11'",
"sphinx-inline-tabs >=2023.4; python_version>='3.11'",
"sphinxcontrib-apidoc >=0.3; python_version>='3.11'",
"furo >=2025.9",
"linkify-it-py >=2.0",
"markdown-it-py >=2.2",
"myst-parser >=5.0",
"python-forge >=18.6",
"sphinx ~=8.2",
"sphinx-automodapi >=0.18,<0.21",
"sphinx-autodoc-typehints >=2.4",
"sphinx-copybutton >=0.5",
"sphinx-inline-tabs >=2023.4",
"sphinxcontrib-apidoc >=0.3",
]

# Test server dependencies
Expand Down Expand Up @@ -116,7 +114,7 @@ exclude_lines = [
]

[tool.mypy]
python_version = "3.9"
python_version = "3.11"
ignore_missing_imports = true
warn_redundant_casts = true
warn_unused_ignores = true
Expand All @@ -133,7 +131,7 @@ asyncio_default_fixture_loop_scope = "function"
[tool.ruff]
line-length = 100
output-format = 'grouped'
target-version = 'py39'
target-version = 'py311'

[tool.ruff.format]
quote-style = 'single'
Expand Down
3 changes: 1 addition & 2 deletions test/integration/base_backend_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from uuid import uuid4

import pytest
from async_timeout import timeout
from itsdangerous.exc import BadSignature
from itsdangerous.serializer import Serializer
from yarl import URL
Expand Down Expand Up @@ -153,7 +152,7 @@ async def test_without_contextmanager(self):
An "unclosed ClientSession" warning is expected here, however.
"""
# Timeout to avoid hanging if the test fails
async with timeout(5.0):
async with asyncio.timeout(5.0):
session = await self._init_session()
await session.get(httpbin('get'))
del session
Expand Down
Loading
Loading