niquests-cache 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ *.kate-swp
2
+ *.log
3
+ *~
4
+ .*.swp
5
+ .*_cache/
6
+ .DS_Store*
7
+ .coverage
8
+ .cspellcache
9
+ .directory
10
+ .pnp.*
11
+ .venv/
12
+ /.claude/settings.json
13
+ /.claude/settings.local.json
14
+ /.cursor/plans/
15
+ /.wiswa-ci/
16
+ /.yarn/install-state.gz
17
+ /build/
18
+ /dist/
19
+ /docs/_build/
20
+ /man/_static/
21
+ /mypy-report*/
22
+ __pycache__/
23
+ cobertura.xml
24
+ coverage.xml
25
+ htmlcov/
26
+ mypy-report.xml
27
+ node_modules/
@@ -0,0 +1,18 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 niquests-cache authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction,
7
+ including without limitation the rights to use, copy, modify, merge, publish, distribute,
8
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or
12
+ substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
15
+ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
18
+ OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: niquests-cache
3
+ Version: 0.0.1
4
+ Summary: Filesystem-cached niquests sessions.
5
+ Project-URL: Issues, https://github.com/Tatsh/niquests-cache/issues
6
+ Project-URL: documentation, https://niquests-cache.readthedocs.org
7
+ Project-URL: homepage, https://tatsh.github.io/niquests-cache/
8
+ Project-URL: repository, https://github.com/Tatsh/niquests-cache
9
+ Author-email: Andrew Udvare <audvare@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE.txt
12
+ Keywords: cache,filesystem,http,niquests
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: <4.0,>=3.10
23
+ Requires-Dist: niquests>=3.18.4
24
+ Requires-Dist: platformdirs>=4.9.4
25
+ Requires-Dist: typing-extensions>=4.15.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # niquests-cache
29
+
30
+ <!-- WISWA-GENERATED-README:START -->
31
+
32
+ [![Python versions](https://img.shields.io/pypi/pyversions/niquests-cache.svg?color=blue&logo=python&logoColor=white)](https://www.python.org/)
33
+ [![PyPI - Version](https://img.shields.io/pypi/v/niquests-cache)](https://pypi.org/project/niquests-cache/)
34
+ [![GitHub tag (with filter)](https://img.shields.io/github/v/tag/Tatsh/niquests-cache)](https://github.com/Tatsh/niquests-cache/tags)
35
+ [![License](https://img.shields.io/github/license/Tatsh/niquests-cache)](https://github.com/Tatsh/niquests-cache/blob/master/LICENSE.txt)
36
+ [![GitHub commits since latest release (by SemVer including pre-releases)](https://img.shields.io/github/commits-since/Tatsh/niquests-cache/v0.0.1/master)](https://github.com/Tatsh/niquests-cache/compare/v0.0.1...master)
37
+ [![CodeQL](https://github.com/Tatsh/niquests-cache/actions/workflows/codeql.yml/badge.svg)](https://github.com/Tatsh/niquests-cache/actions/workflows/codeql.yml)
38
+ [![QA](https://github.com/Tatsh/niquests-cache/actions/workflows/qa.yml/badge.svg)](https://github.com/Tatsh/niquests-cache/actions/workflows/qa.yml)
39
+ [![Tests](https://github.com/Tatsh/niquests-cache/actions/workflows/tests.yml/badge.svg)](https://github.com/Tatsh/niquests-cache/actions/workflows/tests.yml)
40
+ [![Coverage Status](https://coveralls.io/repos/github/Tatsh/niquests-cache/badge.svg?branch=master)](https://coveralls.io/github/Tatsh/niquests-cache?branch=master)
41
+ [![Dependabot](https://img.shields.io/badge/Dependabot-enabled-blue?logo=dependabot)](https://github.com/dependabot)
42
+ [![Documentation Status](https://readthedocs.org/projects/niquests-cache/badge/?version=latest)](https://niquests-cache.readthedocs.org/?badge=latest)
43
+ [![mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
44
+ [![uv](https://img.shields.io/badge/uv-261230?logo=astral)](https://docs.astral.sh/uv/)
45
+ [![pytest](https://img.shields.io/badge/pytest-zz?logo=Pytest&labelColor=black&color=black)](https://docs.pytest.org/en/stable/)
46
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
47
+ [![Downloads](https://static.pepy.tech/badge/niquests-cache/month)](https://pepy.tech/project/niquests-cache)
48
+ [![Stargazers](https://img.shields.io/github/stars/Tatsh/niquests-cache?logo=github&style=flat)](https://github.com/Tatsh/niquests-cache/stargazers)
49
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
50
+ [![Prettier](https://img.shields.io/badge/Prettier-black?logo=prettier)](https://prettier.io/)
51
+
52
+ [![@Tatsh](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpublic.api.bsky.app%2Fxrpc%2Fapp.bsky.actor.getProfile%2F%3Factor=did%3Aplc%3Auq42idtvuccnmtl57nsucz72&query=%24.followersCount&label=Follow+%40Tatsh&logo=bluesky&style=social)](https://bsky.app/profile/Tatsh.bsky.social)
53
+ [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Tatsh-black?logo=buymeacoffee)](https://buymeacoffee.com/Tatsh)
54
+ [![Libera.Chat](https://img.shields.io/badge/Libera.Chat-Tatsh-black?logo=liberadotchat)](irc://irc.libera.chat/Tatsh)
55
+ [![Mastodon Follow](https://img.shields.io/mastodon/follow/109370961877277568?domain=hostux.social&style=social)](https://hostux.social/@Tatsh)
56
+ [![Patreon](https://img.shields.io/badge/Patreon-Tatsh2-F96854?logo=patreon)](https://www.patreon.com/Tatsh2)
57
+
58
+ <!-- WISWA-GENERATED-README:STOP -->
59
+
60
+ Filesystem-cached niquests sessions.
61
+
62
+ ## Installation
63
+
64
+ ```shell
65
+ pip install niquests-cache
66
+ ```
67
+
68
+ ## Example usage
69
+
70
+ The `cached_session()` helper returns a `CachedSession` or `CachedAsyncSession` whose cache root
71
+ is `platformdirs.user_cache_path(app_name, appauthor=False) / 'http'`. If you omit `app_name`,
72
+ `niquests-cache` is used. Only successful `GET` and `HEAD` responses are written to disk; the
73
+ default time-to-live is 10 minutes (`expire_after=` on the helper, or per-request—see below).
74
+
75
+ Sync helper (default app name and TTL):
76
+
77
+ ```python
78
+ from niquests_cache import cached_session
79
+
80
+ session = cached_session()
81
+ response = session.get('https://httpbin.org/get')
82
+ response.raise_for_status()
83
+ ```
84
+
85
+ Custom application name for `user_cache_path` (same `http` subdirectory):
86
+
87
+ ```python
88
+ from niquests_cache import cached_session
89
+
90
+ session = cached_session(app_name='my-application')
91
+ response = session.get('https://httpbin.org/get')
92
+ response.raise_for_status()
93
+ ```
94
+
95
+ Plain niquests session with no filesystem cache:
96
+
97
+ ```python
98
+ from niquests_cache import cached_session
99
+
100
+ session = cached_session(no_cache=True)
101
+ ```
102
+
103
+ Construct `CachedSession` when you need an explicit directory or TTL:
104
+
105
+ ```python
106
+ from datetime import timedelta
107
+ from pathlib import Path
108
+
109
+ from niquests_cache import CachedSession
110
+
111
+ cache = Path('.cache') / 'http'
112
+ with CachedSession(cache_dir=cache, expire_after=timedelta(hours=1)) as session:
113
+ response = session.get('https://httpbin.org/get')
114
+ response.raise_for_status()
115
+ ```
116
+
117
+ Async helper (use an async context manager):
118
+
119
+ ```python
120
+ import asyncio
121
+ from datetime import timedelta
122
+
123
+ from niquests_cache import cached_session
124
+
125
+ async def main() -> None:
126
+ session = cached_session(aio=True, expire_after=timedelta(minutes=30))
127
+ async with session:
128
+ response = await session.get('https://httpbin.org/get')
129
+ response.raise_for_status()
130
+
131
+ asyncio.run(main())
132
+ ```
133
+
134
+ Or construct `CachedAsyncSession` directly:
135
+
136
+ ```python
137
+ import asyncio
138
+ from datetime import timedelta
139
+ from pathlib import Path
140
+
141
+ from niquests_cache import CachedAsyncSession
142
+
143
+ async def main() -> None:
144
+ cache = Path('.cache') / 'http'
145
+ async with CachedAsyncSession(cache_dir=cache, expire_after=timedelta(hours=1)) as session:
146
+ response = await session.get('https://httpbin.org/get')
147
+ response.raise_for_status()
148
+
149
+ asyncio.run(main())
150
+ ```
151
+
152
+ To bypass the cache for a single request, pass `expire_after=0` to `request` (that `GET` or
153
+ `HEAD` is not served from or written to the cache).
@@ -0,0 +1,126 @@
1
+ # niquests-cache
2
+
3
+ <!-- WISWA-GENERATED-README:START -->
4
+
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/niquests-cache.svg?color=blue&logo=python&logoColor=white)](https://www.python.org/)
6
+ [![PyPI - Version](https://img.shields.io/pypi/v/niquests-cache)](https://pypi.org/project/niquests-cache/)
7
+ [![GitHub tag (with filter)](https://img.shields.io/github/v/tag/Tatsh/niquests-cache)](https://github.com/Tatsh/niquests-cache/tags)
8
+ [![License](https://img.shields.io/github/license/Tatsh/niquests-cache)](https://github.com/Tatsh/niquests-cache/blob/master/LICENSE.txt)
9
+ [![GitHub commits since latest release (by SemVer including pre-releases)](https://img.shields.io/github/commits-since/Tatsh/niquests-cache/v0.0.1/master)](https://github.com/Tatsh/niquests-cache/compare/v0.0.1...master)
10
+ [![CodeQL](https://github.com/Tatsh/niquests-cache/actions/workflows/codeql.yml/badge.svg)](https://github.com/Tatsh/niquests-cache/actions/workflows/codeql.yml)
11
+ [![QA](https://github.com/Tatsh/niquests-cache/actions/workflows/qa.yml/badge.svg)](https://github.com/Tatsh/niquests-cache/actions/workflows/qa.yml)
12
+ [![Tests](https://github.com/Tatsh/niquests-cache/actions/workflows/tests.yml/badge.svg)](https://github.com/Tatsh/niquests-cache/actions/workflows/tests.yml)
13
+ [![Coverage Status](https://coveralls.io/repos/github/Tatsh/niquests-cache/badge.svg?branch=master)](https://coveralls.io/github/Tatsh/niquests-cache?branch=master)
14
+ [![Dependabot](https://img.shields.io/badge/Dependabot-enabled-blue?logo=dependabot)](https://github.com/dependabot)
15
+ [![Documentation Status](https://readthedocs.org/projects/niquests-cache/badge/?version=latest)](https://niquests-cache.readthedocs.org/?badge=latest)
16
+ [![mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
17
+ [![uv](https://img.shields.io/badge/uv-261230?logo=astral)](https://docs.astral.sh/uv/)
18
+ [![pytest](https://img.shields.io/badge/pytest-zz?logo=Pytest&labelColor=black&color=black)](https://docs.pytest.org/en/stable/)
19
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
20
+ [![Downloads](https://static.pepy.tech/badge/niquests-cache/month)](https://pepy.tech/project/niquests-cache)
21
+ [![Stargazers](https://img.shields.io/github/stars/Tatsh/niquests-cache?logo=github&style=flat)](https://github.com/Tatsh/niquests-cache/stargazers)
22
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
23
+ [![Prettier](https://img.shields.io/badge/Prettier-black?logo=prettier)](https://prettier.io/)
24
+
25
+ [![@Tatsh](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpublic.api.bsky.app%2Fxrpc%2Fapp.bsky.actor.getProfile%2F%3Factor=did%3Aplc%3Auq42idtvuccnmtl57nsucz72&query=%24.followersCount&label=Follow+%40Tatsh&logo=bluesky&style=social)](https://bsky.app/profile/Tatsh.bsky.social)
26
+ [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Tatsh-black?logo=buymeacoffee)](https://buymeacoffee.com/Tatsh)
27
+ [![Libera.Chat](https://img.shields.io/badge/Libera.Chat-Tatsh-black?logo=liberadotchat)](irc://irc.libera.chat/Tatsh)
28
+ [![Mastodon Follow](https://img.shields.io/mastodon/follow/109370961877277568?domain=hostux.social&style=social)](https://hostux.social/@Tatsh)
29
+ [![Patreon](https://img.shields.io/badge/Patreon-Tatsh2-F96854?logo=patreon)](https://www.patreon.com/Tatsh2)
30
+
31
+ <!-- WISWA-GENERATED-README:STOP -->
32
+
33
+ Filesystem-cached niquests sessions.
34
+
35
+ ## Installation
36
+
37
+ ```shell
38
+ pip install niquests-cache
39
+ ```
40
+
41
+ ## Example usage
42
+
43
+ The `cached_session()` helper returns a `CachedSession` or `CachedAsyncSession` whose cache root
44
+ is `platformdirs.user_cache_path(app_name, appauthor=False) / 'http'`. If you omit `app_name`,
45
+ `niquests-cache` is used. Only successful `GET` and `HEAD` responses are written to disk; the
46
+ default time-to-live is 10 minutes (`expire_after=` on the helper, or per-request—see below).
47
+
48
+ Sync helper (default app name and TTL):
49
+
50
+ ```python
51
+ from niquests_cache import cached_session
52
+
53
+ session = cached_session()
54
+ response = session.get('https://httpbin.org/get')
55
+ response.raise_for_status()
56
+ ```
57
+
58
+ Custom application name for `user_cache_path` (same `http` subdirectory):
59
+
60
+ ```python
61
+ from niquests_cache import cached_session
62
+
63
+ session = cached_session(app_name='my-application')
64
+ response = session.get('https://httpbin.org/get')
65
+ response.raise_for_status()
66
+ ```
67
+
68
+ Plain niquests session with no filesystem cache:
69
+
70
+ ```python
71
+ from niquests_cache import cached_session
72
+
73
+ session = cached_session(no_cache=True)
74
+ ```
75
+
76
+ Construct `CachedSession` when you need an explicit directory or TTL:
77
+
78
+ ```python
79
+ from datetime import timedelta
80
+ from pathlib import Path
81
+
82
+ from niquests_cache import CachedSession
83
+
84
+ cache = Path('.cache') / 'http'
85
+ with CachedSession(cache_dir=cache, expire_after=timedelta(hours=1)) as session:
86
+ response = session.get('https://httpbin.org/get')
87
+ response.raise_for_status()
88
+ ```
89
+
90
+ Async helper (use an async context manager):
91
+
92
+ ```python
93
+ import asyncio
94
+ from datetime import timedelta
95
+
96
+ from niquests_cache import cached_session
97
+
98
+ async def main() -> None:
99
+ session = cached_session(aio=True, expire_after=timedelta(minutes=30))
100
+ async with session:
101
+ response = await session.get('https://httpbin.org/get')
102
+ response.raise_for_status()
103
+
104
+ asyncio.run(main())
105
+ ```
106
+
107
+ Or construct `CachedAsyncSession` directly:
108
+
109
+ ```python
110
+ import asyncio
111
+ from datetime import timedelta
112
+ from pathlib import Path
113
+
114
+ from niquests_cache import CachedAsyncSession
115
+
116
+ async def main() -> None:
117
+ cache = Path('.cache') / 'http'
118
+ async with CachedAsyncSession(cache_dir=cache, expire_after=timedelta(hours=1)) as session:
119
+ response = await session.get('https://httpbin.org/get')
120
+ response.raise_for_status()
121
+
122
+ asyncio.run(main())
123
+ ```
124
+
125
+ To bypass the cache for a single request, pass `expire_after=0` to `request` (that `GET` or
126
+ `HEAD` is not served from or written to the cache).
@@ -0,0 +1,7 @@
1
+ """Filesystem-cached :mod:`niquests` sessions."""
2
+ from __future__ import annotations
3
+
4
+ from niquests_cache.session import CachedAsyncSession, CachedSession, cached_session
5
+
6
+ __all__ = ('CachedAsyncSession', 'CachedSession', 'cached_session')
7
+ __version__ = '0.0.1'
File without changes
@@ -0,0 +1,225 @@
1
+ """Shared :mod:`niquests` cached sessions (sync and async)."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import timedelta
5
+ from hashlib import sha256
6
+ from os import PathLike
7
+ from pathlib import Path
8
+ from time import time
9
+ from typing import Any, cast
10
+ import contextlib
11
+ import json
12
+ import logging
13
+
14
+ import niquests
15
+ import platformdirs
16
+
17
+ __all__ = ('CachedAsyncSession', 'CachedSession', 'cached_session')
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ _DEFAULT_EXPIRE = timedelta(minutes=10)
22
+
23
+ StrPath = str | PathLike[str]
24
+
25
+
26
+ def _cache_key(cache_dir: Path, method: str, url: str) -> Path:
27
+ digest = sha256(f'{method} {url}'.encode()).hexdigest()
28
+ return cache_dir / digest
29
+
30
+
31
+ def _response_from_cache_entry(data: dict[str, Any]) -> niquests.Response:
32
+ resp = niquests.Response()
33
+ resp.status_code = data['status_code']
34
+ resp._content = data['content'].encode('utf-8') # noqa: SLF001
35
+ resp.headers.update(data['headers'])
36
+ resp.url = data['url']
37
+ resp.encoding = data.get('encoding', 'utf-8')
38
+ return resp
39
+
40
+
41
+ def _try_read_cache(
42
+ cache_path: Path,
43
+ ttl: float,
44
+ method: str,
45
+ url: str,
46
+ ) -> niquests.Response | None:
47
+ if not cache_path.exists():
48
+ return None
49
+ try:
50
+ data = json.loads(cache_path.read_text(encoding='utf-8'))
51
+ if time() - data['ts'] < ttl:
52
+ log.debug('Cache hit: %s %s', method, url)
53
+ return _response_from_cache_entry(data)
54
+ except (json.JSONDecodeError, KeyError, OSError):
55
+ log.debug('Failed to read cache entry: %s', cache_path, exc_info=True)
56
+ return None
57
+
58
+
59
+ def _write_cache(cache_path: Path, resp: niquests.Response) -> None:
60
+ with contextlib.suppress(OSError): # pragma: no cover
61
+ cache_path.write_text(
62
+ json.dumps({
63
+ 'ts': time(),
64
+ 'status_code': resp.status_code,
65
+ 'content': resp.text or '',
66
+ 'headers': dict(resp.headers),
67
+ 'url': str(resp.url),
68
+ 'encoding': resp.encoding,
69
+ }),
70
+ encoding='utf-8',
71
+ )
72
+
73
+
74
+ class CachedSession(niquests.Session):
75
+ """A synchronous niquests session with simple filesystem response caching."""
76
+ def __init__(
77
+ self,
78
+ cache_dir: StrPath,
79
+ expire_after: timedelta = _DEFAULT_EXPIRE,
80
+ **kwargs: Any,
81
+ ) -> None:
82
+ super().__init__(**kwargs)
83
+ self._cache_dir = Path(cache_dir)
84
+ self._expire_seconds = expire_after.total_seconds()
85
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
86
+
87
+ @property
88
+ def cache_directory(self) -> Path:
89
+ """Filesystem directory used for this session's response cache."""
90
+ return self._cache_dir
91
+
92
+ @property
93
+ def expire_after_total_seconds(self) -> float:
94
+ """Cache TTL in seconds for GET and HEAD responses."""
95
+ return self._expire_seconds
96
+
97
+ def request( # type: ignore[override]
98
+ self,
99
+ method: str,
100
+ url: str,
101
+ *,
102
+ expire_after: float | None = None,
103
+ **kwargs: Any,
104
+ ) -> niquests.Response:
105
+ """
106
+ Send a request, returning a cached response when available.
107
+
108
+ Returns
109
+ -------
110
+ niquests.Response
111
+ The HTTP response.
112
+ """
113
+ bypass = expire_after == 0
114
+ ttl = self._expire_seconds if expire_after is None else expire_after
115
+ if method.upper() in {'GET', 'HEAD'} and not bypass:
116
+ cache_path = _cache_key(self._cache_dir, method, url)
117
+ hit = _try_read_cache(cache_path, ttl, method, url)
118
+ if hit is not None:
119
+ return hit
120
+ resp = super().request(method, url, **kwargs)
121
+ if method.upper() in {'GET', 'HEAD'} and resp.ok and not bypass:
122
+ log.debug('Caching response: %s %s', method, url)
123
+ _write_cache(_cache_key(self._cache_dir, method, url), resp)
124
+ return resp
125
+
126
+
127
+ class CachedAsyncSession(niquests.AsyncSession):
128
+ """An async niquests session with simple filesystem response caching."""
129
+ def __init__(
130
+ self,
131
+ cache_dir: StrPath,
132
+ expire_after: timedelta = _DEFAULT_EXPIRE,
133
+ **kwargs: Any,
134
+ ) -> None:
135
+ super().__init__(**kwargs)
136
+ self._cache_dir = Path(cache_dir)
137
+ self._expire_seconds = expire_after.total_seconds()
138
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
139
+
140
+ @property
141
+ def cache_directory(self) -> Path:
142
+ """Filesystem directory used for this session's response cache."""
143
+ return self._cache_dir
144
+
145
+ @property
146
+ def expire_after_total_seconds(self) -> float:
147
+ """Cache TTL in seconds for GET and HEAD responses."""
148
+ return self._expire_seconds
149
+
150
+ async def request( # type: ignore[override]
151
+ self,
152
+ method: str,
153
+ url: str,
154
+ *,
155
+ expire_after: float | None = None,
156
+ **kwargs: Any,
157
+ ) -> niquests.Response:
158
+ """
159
+ Send a request, returning a cached response when available.
160
+
161
+ Parameters
162
+ ----------
163
+ method : str
164
+ The HTTP method.
165
+ url : str
166
+ The URL.
167
+ expire_after : float | None
168
+ Override cache expiry for this request. Set to ``0`` to bypass the cache.
169
+ **kwargs : Any
170
+ Additional keyword arguments passed to the parent.
171
+
172
+ Returns
173
+ -------
174
+ niquests.Response
175
+ The HTTP response.
176
+ """
177
+ bypass = expire_after == 0
178
+ ttl = self._expire_seconds if expire_after is None else expire_after
179
+ if method.upper() in {'GET', 'HEAD'} and not bypass:
180
+ cache_path = _cache_key(self._cache_dir, method, url)
181
+ hit = _try_read_cache(cache_path, ttl, method, url)
182
+ if hit is not None:
183
+ return hit
184
+ resp = cast('niquests.Response', await super().request(method, url, **kwargs))
185
+ if method.upper() in {'GET', 'HEAD'} and resp.ok and not bypass:
186
+ log.debug('Caching response: %s %s', method, url)
187
+ _write_cache(_cache_key(self._cache_dir, method, url), resp)
188
+ return resp
189
+
190
+
191
+ def cached_session(
192
+ *,
193
+ aio: bool = False,
194
+ no_cache: bool = False,
195
+ app_name: str | None = None,
196
+ expire_after: timedelta = _DEFAULT_EXPIRE,
197
+ ) -> niquests.Session | niquests.AsyncSession:
198
+ """
199
+ Get a niquests session, optionally with filesystem caching.
200
+
201
+ Parameters
202
+ ----------
203
+ aio : bool
204
+ If ``True``, return an async session; otherwise a synchronous session.
205
+ no_cache : bool
206
+ If ``True``, return a plain session without caching.
207
+ app_name : str | None
208
+ First argument to :func:`platformdirs.user_cache_path` for the cache root. If ``None``,
209
+ uses ``niquests-cache``. Cached entries live under ``<user cache path> / 'http'``.
210
+ expire_after : timedelta
211
+ Cache expiry duration (ignored when *no_cache* is ``True``).
212
+
213
+ Returns
214
+ -------
215
+ niquests.Session | niquests.AsyncSession
216
+ A session instance (use async context manager when *aio* is ``True``).
217
+ """
218
+ if no_cache:
219
+ return niquests.AsyncSession() if aio else niquests.Session()
220
+ cache_dir = platformdirs.user_cache_path('niquests-cache' if app_name is None else app_name,
221
+ appauthor=False,
222
+ ensure_exists=True) / 'http'
223
+ if aio:
224
+ return CachedAsyncSession(cache_dir=cache_dir, expire_after=expire_after)
225
+ return CachedSession(cache_dir=cache_dir, expire_after=expire_after)