httpx-frugal 0.2.0__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,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .coverage
11
+ htmlcov/
12
+ *.sqlite
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.2.0] - 2026-05-16
6
+
7
+ ### Added
8
+
9
+ - `AsyncRateLimitedCacheClient` for async httpx pipelines (FastAPI, Dagster, etc.)
10
+ - `blocking` and `rate_limit_timeout_seconds` on sync and async clients
11
+ - `use_file_lock` for multi-process rate limiter safety
12
+ - `tokens_available(domain)` for rate-limit introspection
13
+ - `clear_cache_for(url)` for surgical cache invalidation
14
+ - `request_with_ttl()` helper and per-request `hishel_ttl` extension support
15
+ - `enable_http2` flag (default `False`; opt in with `httpx-frugal[http2]`)
16
+
17
+ ### Fixed
18
+
19
+ - Path validation used wrong variable when cache parent directory was missing
20
+ - `validate_rate_list` now raises `ValueError` instead of `assert` (safe under `python -O`)
21
+ - Rate limiting rejects URLs without a host
22
+
23
+ ### Changed
24
+
25
+ - HTTP/2 disabled by default to avoid requiring `httpx[http2]` on install
26
+ - `hishel[async]` included in core dependencies for async SQLite storage
27
+
28
+ ## [0.1.0] - 2026-05-16
29
+
30
+ ### Added
31
+
32
+ - Initial release: `RateLimitedCacheClient` with SQLite cache and rate limiting
33
+ - `would_hit_cache()`, `clear_cache()`, `clear_rate_limiter()`
34
+ - `DomainRateLimitedTransport` and `AlwaysCachePolicy`
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: httpx-frugal
3
+ Version: 0.2.0
4
+ Summary: An httpx client with persistent caching and rate limiting for data pipelines
5
+ Project-URL: Homepage, https://github.com/httpx-frugal/httpx-frugal
6
+ Project-URL: Documentation, https://github.com/httpx-frugal/httpx-frugal#readme
7
+ Project-URL: Repository, https://github.com/httpx-frugal/httpx-frugal
8
+ Author: httpx-frugal contributors
9
+ License: MIT
10
+ Keywords: cache,hishel,httpx,pyrate-limiter,rate-limit
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: hishel[async]<2,>=1.2
22
+ Requires-Dist: httpx<1,>=0.27
23
+ Requires-Dist: pyrate-limiter<5,>=4
24
+ Provides-Extra: async
25
+ Requires-Dist: hishel[async]; extra == 'async'
26
+ Provides-Extra: http2
27
+ Requires-Dist: httpx[http2]; extra == 'http2'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # httpx-frugal
31
+
32
+ An [httpx](https://www.python-httpx.org/) client with persistent caching ([hishel](https://hishel.com/)) and per-domain rate limiting ([pyrate-limiter](https://pyratelimiter.readthedocs.io/)), designed for data pipelines that need to conserve API quota across process restarts.
33
+
34
+ Cache is checked **before** rate limiting, so cached responses do not consume rate-limit tokens.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install httpx-frugal
40
+ ```
41
+
42
+ Optional extras:
43
+
44
+ ```bash
45
+ pip install httpx-frugal[http2] # HTTP/2 inner transport
46
+ ```
47
+
48
+ ## Quickstart (sync)
49
+
50
+ ```python
51
+ import pathlib
52
+
53
+ from pyrate_limiter import Duration, Rate
54
+
55
+ from httpx_frugal import RateLimitedCacheClient
56
+
57
+ rates = [Rate(5, Duration.MINUTE)]
58
+ cache_db = pathlib.Path("~/.cache/myapp/http-cache.sqlite").expanduser()
59
+ rate_db = pathlib.Path("~/.cache/myapp/rate-limiter.sqlite").expanduser()
60
+ cache_db.parent.mkdir(parents=True, exist_ok=True)
61
+ rate_db.parent.mkdir(parents=True, exist_ok=True)
62
+
63
+ client_wrapper = RateLimitedCacheClient(
64
+ rates=rates,
65
+ cache_db_path=cache_db,
66
+ rate_limiter_db_path=rate_db,
67
+ )
68
+
69
+ if client_wrapper.would_hit_cache("https://api.example.com/data"):
70
+ print("served from cache on next request")
71
+
72
+ with client_wrapper as client:
73
+ response = client.get("https://api.example.com/data")
74
+ print(response.extensions.get("hishel_from_cache"))
75
+ ```
76
+
77
+ ## Async
78
+
79
+ ```python
80
+ from httpx_frugal import AsyncRateLimitedCacheClient
81
+
82
+ async with AsyncRateLimitedCacheClient(
83
+ rates=rates,
84
+ cache_db_path=cache_db,
85
+ rate_limiter_db_path=rate_db,
86
+ ) as client:
87
+ response = await client.get("https://api.example.com/data")
88
+ ```
89
+
90
+ ## Blocking mode
91
+
92
+ Wait for a rate-limit token instead of raising immediately:
93
+
94
+ ```python
95
+ client_wrapper = RateLimitedCacheClient(
96
+ rates=rates,
97
+ cache_db_path=cache_db,
98
+ rate_limiter_db_path=rate_db,
99
+ blocking=True,
100
+ rate_limit_timeout_seconds=30.0,
101
+ )
102
+ ```
103
+
104
+ ## Per-request cache TTL
105
+
106
+ Override TTL for a single request via httpx extensions:
107
+
108
+ ```python
109
+ with client_wrapper as client:
110
+ client.get("https://api.example.com/short-lived", extensions={"hishel_ttl": 60})
111
+ ```
112
+
113
+ Or use the helper:
114
+
115
+ ```python
116
+ from httpx_frugal import request_with_ttl
117
+
118
+ with client_wrapper as client:
119
+ req = request_with_ttl(client, "GET", "https://api.example.com/x", ttl=120)
120
+ client.send(req)
121
+ ```
122
+
123
+ ## Rate limit introspection
124
+
125
+ ```python
126
+ remaining = client_wrapper.tokens_available("api.example.com")
127
+ # e.g. {"5/60000s": 3}
128
+ ```
129
+
130
+ ## Cache invalidation
131
+
132
+ ```python
133
+ client_wrapper.clear_cache() # all entries
134
+ client_wrapper.clear_cache_for("https://...") # one URL
135
+ client_wrapper.clear_rate_limiter() # reset tokens
136
+ ```
137
+
138
+ ## Multi-process pipelines
139
+
140
+ Enable SQLite file locking on the rate limiter bucket:
141
+
142
+ ```python
143
+ RateLimitedCacheClient(..., use_file_lock=True)
144
+ ```
145
+
146
+ ## `would_hit_cache` caveat
147
+
148
+ `would_hit_cache()` uses hishel internal APIs and may break if hishel changes them. Prefer checking `response.extensions["hishel_from_cache"]` after requests when possible. httpx-frugal pins `hishel>=1.2,<2`.
149
+
150
+ ## Development
151
+
152
+ ```bash
153
+ uv sync
154
+ uv run ruff check .
155
+ uv run pytest
156
+ ```
157
+
158
+ ## License
159
+
160
+ MIT
@@ -0,0 +1,131 @@
1
+ # httpx-frugal
2
+
3
+ An [httpx](https://www.python-httpx.org/) client with persistent caching ([hishel](https://hishel.com/)) and per-domain rate limiting ([pyrate-limiter](https://pyratelimiter.readthedocs.io/)), designed for data pipelines that need to conserve API quota across process restarts.
4
+
5
+ Cache is checked **before** rate limiting, so cached responses do not consume rate-limit tokens.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install httpx-frugal
11
+ ```
12
+
13
+ Optional extras:
14
+
15
+ ```bash
16
+ pip install httpx-frugal[http2] # HTTP/2 inner transport
17
+ ```
18
+
19
+ ## Quickstart (sync)
20
+
21
+ ```python
22
+ import pathlib
23
+
24
+ from pyrate_limiter import Duration, Rate
25
+
26
+ from httpx_frugal import RateLimitedCacheClient
27
+
28
+ rates = [Rate(5, Duration.MINUTE)]
29
+ cache_db = pathlib.Path("~/.cache/myapp/http-cache.sqlite").expanduser()
30
+ rate_db = pathlib.Path("~/.cache/myapp/rate-limiter.sqlite").expanduser()
31
+ cache_db.parent.mkdir(parents=True, exist_ok=True)
32
+ rate_db.parent.mkdir(parents=True, exist_ok=True)
33
+
34
+ client_wrapper = RateLimitedCacheClient(
35
+ rates=rates,
36
+ cache_db_path=cache_db,
37
+ rate_limiter_db_path=rate_db,
38
+ )
39
+
40
+ if client_wrapper.would_hit_cache("https://api.example.com/data"):
41
+ print("served from cache on next request")
42
+
43
+ with client_wrapper as client:
44
+ response = client.get("https://api.example.com/data")
45
+ print(response.extensions.get("hishel_from_cache"))
46
+ ```
47
+
48
+ ## Async
49
+
50
+ ```python
51
+ from httpx_frugal import AsyncRateLimitedCacheClient
52
+
53
+ async with AsyncRateLimitedCacheClient(
54
+ rates=rates,
55
+ cache_db_path=cache_db,
56
+ rate_limiter_db_path=rate_db,
57
+ ) as client:
58
+ response = await client.get("https://api.example.com/data")
59
+ ```
60
+
61
+ ## Blocking mode
62
+
63
+ Wait for a rate-limit token instead of raising immediately:
64
+
65
+ ```python
66
+ client_wrapper = RateLimitedCacheClient(
67
+ rates=rates,
68
+ cache_db_path=cache_db,
69
+ rate_limiter_db_path=rate_db,
70
+ blocking=True,
71
+ rate_limit_timeout_seconds=30.0,
72
+ )
73
+ ```
74
+
75
+ ## Per-request cache TTL
76
+
77
+ Override TTL for a single request via httpx extensions:
78
+
79
+ ```python
80
+ with client_wrapper as client:
81
+ client.get("https://api.example.com/short-lived", extensions={"hishel_ttl": 60})
82
+ ```
83
+
84
+ Or use the helper:
85
+
86
+ ```python
87
+ from httpx_frugal import request_with_ttl
88
+
89
+ with client_wrapper as client:
90
+ req = request_with_ttl(client, "GET", "https://api.example.com/x", ttl=120)
91
+ client.send(req)
92
+ ```
93
+
94
+ ## Rate limit introspection
95
+
96
+ ```python
97
+ remaining = client_wrapper.tokens_available("api.example.com")
98
+ # e.g. {"5/60000s": 3}
99
+ ```
100
+
101
+ ## Cache invalidation
102
+
103
+ ```python
104
+ client_wrapper.clear_cache() # all entries
105
+ client_wrapper.clear_cache_for("https://...") # one URL
106
+ client_wrapper.clear_rate_limiter() # reset tokens
107
+ ```
108
+
109
+ ## Multi-process pipelines
110
+
111
+ Enable SQLite file locking on the rate limiter bucket:
112
+
113
+ ```python
114
+ RateLimitedCacheClient(..., use_file_lock=True)
115
+ ```
116
+
117
+ ## `would_hit_cache` caveat
118
+
119
+ `would_hit_cache()` uses hishel internal APIs and may break if hishel changes them. Prefer checking `response.extensions["hishel_from_cache"]` after requests when possible. httpx-frugal pins `hishel>=1.2,<2`.
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ uv sync
125
+ uv run ruff check .
126
+ uv run pytest
127
+ ```
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,295 @@
1
+ """ http_client.py """
2
+
3
+ import hashlib
4
+ import sqlite3
5
+ from dataclasses import dataclass, field
6
+ import pathlib
7
+
8
+ # Third Party Libraries
9
+ from hishel import FilterPolicy, SyncSqliteStorage
10
+ from hishel.httpx import SyncCacheTransport
11
+ from hishel._core._spec import vary_headers_match
12
+ from hishel._sync_httpx import _httpx_to_internal
13
+
14
+ import httpx
15
+ from pyrate_limiter import Limiter, Rate, SQLiteBucket, validate_rate_list
16
+
17
+ # ------------------------------------------------------------
18
+ # Composable Transports
19
+ # https://www.python-httpx.org/advanced/transports/#custom-transports
20
+ #
21
+ # RateLimitedCacheClient
22
+ # └── CacheTransport (hishel) ← checked first
23
+ # └── RateLimitedTransport ← only called on cache miss
24
+ # └── HTTPTransport(http2=True)
25
+ #
26
+ # ------------------------------------------------------------
27
+
28
+
29
+ @dataclass
30
+ class AlwaysCachePolicy(FilterPolicy):
31
+ """Cache all responses using storage TTL; ignore RFC 9111 freshness headers."""
32
+
33
+ request_filters: list = field(default_factory=list)
34
+ response_filters: list = field(default_factory=list)
35
+
36
+
37
+ class HTTPRateLimitError(Exception):
38
+ """
39
+ Exception raised when the rate limit is exceeded.
40
+ """
41
+ def __init__(self, message: str):
42
+ self.message = message
43
+ super().__init__(self.message)
44
+
45
+
46
+ class DomainRateLimitedTransport(httpx.BaseTransport):
47
+ def __init__(
48
+
49
+ self,
50
+ limiter: Limiter,
51
+ inner_transport: httpx.BaseTransport,
52
+ timeout_seconds: float = 10.0,
53
+ blocking: bool = False,
54
+ ):
55
+ """
56
+ Initialize the transport.
57
+
58
+ Parameters
59
+ ----------
60
+ limiter : :class:`~pyrate_limiter.Limiter`
61
+ Limiter used to control request rate.
62
+ inner_transport : :class:`httpx.BaseTransport`
63
+ Inner transport to use for the request.
64
+ timeout_seconds : float
65
+ Timeout in seconds for acquiring a token.
66
+ blocking : bool
67
+ Whether to block the request until the token is acquired.
68
+ """
69
+ self.limiter = limiter
70
+ self.inner_transport = inner_transport
71
+ self.timeout: float = timeout_seconds if blocking else -1 # cannot have a timeout value if not blocking
72
+ self.blocking: bool = blocking
73
+
74
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
75
+ # Extract domain as the rate limit identity
76
+ domain_name: str = request.url.host # example: "datahenge.com" or "brianpond.com"
77
+
78
+ # Acquire token with explicit timeout
79
+ success = self.limiter.try_acquire(
80
+ name=domain_name,
81
+ blocking=self.blocking,
82
+ timeout=self.timeout
83
+ )
84
+
85
+ if not success:
86
+ raise HTTPRateLimitError(
87
+ f"Rate limit exceeded for domain name = {domain_name}, "
88
+ f"could not acquire token within {self.timeout}s"
89
+ )
90
+
91
+ # Proceed with actual HTTP request
92
+ return self.inner_transport.handle_request(request)
93
+
94
+
95
+ class RateLimitedCacheClient:
96
+ """
97
+ An httpx client for making HTTP requests with rate limiting and caching.
98
+ """
99
+
100
+ @staticmethod
101
+ def get_default_cache_ttl() -> int:
102
+ return 300 # 5 minutes
103
+
104
+ def __init__(self,
105
+ rates: list[Rate],
106
+ cache_db_path: pathlib.Path,
107
+ rate_limiter_db_path: pathlib.Path,
108
+ cache_ttl: int = get_default_cache_ttl()):
109
+ """
110
+ Parameters
111
+ ----------
112
+ rates : list[Rate]
113
+ The rates to use for rate limiting.
114
+ cache_db_path : pathlib.Path
115
+ The path to the SQLite database to use for caching.
116
+ rate_limiter_db_path : pathlib.Path
117
+ The path to the SQLite database to use for rate limiting.
118
+ cache_ttl : int
119
+ The TTL for the cache in seconds.
120
+ """
121
+ self._rates = rates
122
+ self._cache_ttl = cache_ttl
123
+ self._client: httpx.Client | None = None
124
+ self.__init_paths(cache_db_path, rate_limiter_db_path)
125
+
126
+ def __init_paths(self, cache_db_path: pathlib.Path, rate_limiter_db_path: pathlib.Path):
127
+ # Cache database path
128
+ if not cache_db_path.parent.exists():
129
+ raise FileNotFoundError(f"Cache database directory does not exist: {self._cache_db_path.parent}")
130
+ if cache_db_path.exists() and not cache_db_path.is_file():
131
+ raise FileExistsError(f"Cache database is not a file: {cache_db_path}")
132
+ self._cache_db_path = cache_db_path
133
+
134
+ # Rate limiter database path
135
+ if not rate_limiter_db_path.parent.exists():
136
+ raise FileNotFoundError(f"Rate limiter database directory does not exist: {rate_limiter_db_path.parent}")
137
+ if rate_limiter_db_path.exists() and not rate_limiter_db_path.is_file():
138
+ raise FileExistsError(f"Rate limiter database is not a file: {rate_limiter_db_path}")
139
+ self._rate_limiter_db_path = rate_limiter_db_path
140
+
141
+ def __enter__(self) -> httpx.Client: # returns the real httpx.Client
142
+ self._client = self._build_client()
143
+ return self._client # caller gets full httpx.Client
144
+
145
+ def __exit__(self, *args):
146
+ if self._client:
147
+ self._client.close()
148
+ self._client = None
149
+
150
+ def _cache_storage(self) -> SyncSqliteStorage:
151
+ return SyncSqliteStorage(
152
+ database_path=self._cache_db_path,
153
+ default_ttl=self._cache_ttl,
154
+ )
155
+
156
+ def _rate_limiter_bucket(self) -> SQLiteBucket:
157
+ # NOTE: db_path must be set explicitly; otherwise the bucket uses a temp file
158
+ # deleted when the process exits. Set use_file_lock=True for multi-process use.
159
+ return SQLiteBucket.init_from_file(
160
+ self._rates,
161
+ db_path=str(self._rate_limiter_db_path),
162
+ use_file_lock=False,
163
+ )
164
+
165
+ @staticmethod
166
+ def _cache_key_for_url(url: str) -> str:
167
+ return hashlib.sha256(url.encode("utf-8")).hexdigest()
168
+
169
+ def clear_cache(self) -> None:
170
+ """Remove all cached HTTP responses from the hishel SQLite database."""
171
+ if not self._cache_db_path.exists():
172
+ return
173
+ conn = sqlite3.connect(self._cache_db_path)
174
+ try:
175
+ conn.execute("PRAGMA foreign_keys=ON")
176
+ conn.execute("DELETE FROM entries")
177
+ conn.commit()
178
+ finally:
179
+ conn.close()
180
+
181
+ def clear_rate_limiter(self) -> None:
182
+ """Remove all rate-limit token records from the pyrate-limiter SQLite database."""
183
+ if not self._rate_limiter_db_path.exists():
184
+ return
185
+ bucket = self._rate_limiter_bucket()
186
+ try:
187
+ bucket.flush()
188
+ finally:
189
+ bucket.close()
190
+
191
+ def would_hit_cache(self, url: str, *, method: str = "GET", params: dict | None = None) -> bool:
192
+ """
193
+ Return whether FilterPolicy would serve this request from cache.
194
+
195
+ Mirrors the lookup in hishel's ``_handle_request_with_filters`` using
196
+ the same storage path and TTL as :meth:`_build_client`.
197
+ """
198
+ if params is None:
199
+ params = {}
200
+
201
+ with httpx.Client() as httpx_client:
202
+ httpx_request = httpx_client.build_request(method, url=url, params=params)
203
+ hishel_request = _httpx_to_internal(httpx_request)
204
+
205
+ storage = self._cache_storage()
206
+ try:
207
+ cache_key = self._cache_key_for_url(str(hishel_request.url))
208
+ entries = storage.get_entries(cache_key)
209
+ for entry in entries:
210
+ if (
211
+ str(entry.request.url) == str(hishel_request.url)
212
+ and entry.request.method == hishel_request.method
213
+ and vary_headers_match(hishel_request, entry)
214
+ ):
215
+ return True
216
+ return False
217
+ finally:
218
+ storage.close()
219
+
220
+ def _build_client(self) -> httpx.Client:
221
+ """
222
+ Build the transport chain
223
+ """
224
+ # Part One: Rate limiting
225
+
226
+ # NOTE: Rates must be properly ordered:
227
+ # Rates' intervals & limits must be ordered from least to greatest
228
+ # Rates' ratio of limit/interval must be ordered from greatest to least
229
+ # Buckets validate rates during initialization. If using a custom implementation, use the built-in validator:
230
+ assert validate_rate_list(self._rates)
231
+
232
+ bucket = self._rate_limiter_bucket()
233
+ rate_limiter = Limiter(bucket) # rate limit layer (only invoked on cache miss)
234
+
235
+ # NOTE: prerequisites for http2=True = install httpx with the http2 extra: `pip install httpx[http2]`
236
+ rate_transport = DomainRateLimitedTransport(limiter=rate_limiter,
237
+ inner_transport=httpx.HTTPTransport(http2=True)
238
+ )
239
+
240
+ # ----------------
241
+ # Part Two: Cache Setup
242
+ # ----------------
243
+
244
+ cache_transport = SyncCacheTransport(
245
+ next_transport=rate_transport,
246
+ storage=self._cache_storage(),
247
+ policy=AlwaysCachePolicy(),
248
+ )
249
+
250
+ # NOTE: Storage also respects the `hishel_ttl` request metadata, useful to set a custom TTL for a specific request.
251
+ return httpx.Client(transport=cache_transport)
252
+
253
+
254
+ def test_http_client():
255
+ """
256
+ Get a recipe from the DummyJSON API.
257
+
258
+ If you change the recipe number:
259
+ 1. The cache might be missed, depending on the default TTL.
260
+ 2. If you rapidly make multiple requests, each with a different recipe number, you might exceed the rate limit.
261
+ """
262
+ import sys
263
+ from pyrate_limiter import Duration, Rate
264
+
265
+ recipe_number = int(sys.argv[1]) if len(sys.argv) > 1 else 1
266
+ recipe_url = f"https://dummyjson.com/recipes/{recipe_number}"
267
+ print(f"Recipe URL: {recipe_url}")
268
+
269
+ minute_rate = Rate(5, Duration.MINUTE) # 5 requests per minute
270
+ hourly_rate = Rate(15, Duration.HOUR) # 15 requests per hour
271
+ days_rate = Rate(100, Duration.DAY * 2) # 100 requests per 2 days
272
+ my_rates = [minute_rate, hourly_rate, days_rate]
273
+
274
+ my_cache_database = pathlib.Path.home() / ".cache/cascadia-watch/http-cache.sqlite"
275
+ my_rate_limiter_database = pathlib.Path.home() / ".cache/cascadia-watch/rate-limiter.sqlite"
276
+
277
+ cache_client = RateLimitedCacheClient(
278
+ rates=my_rates,
279
+ cache_db_path=my_cache_database,
280
+ rate_limiter_db_path=my_rate_limiter_database,
281
+ )
282
+ print(f"\nWould hit cache (before request) = {cache_client.would_hit_cache(recipe_url)}")
283
+
284
+ with cache_client as http_client:
285
+ response = http_client.get(recipe_url)
286
+ print(f"Response was from cache = {response.extensions['hishel_from_cache']}")
287
+ print(f"Response status code = {response.status_code}")
288
+ # print(f"Response headers = {response.headers}")
289
+ print(f"ResponseCache-Control: {response.headers.get('cache-control', 'not set')}")
290
+ print(f"Response content = {response.text}")
291
+
292
+
293
+ if __name__ == "__main__":
294
+ test_http_client()
295
+
@@ -0,0 +1,66 @@
1
+ [project]
2
+ name = "httpx-frugal"
3
+ version = "0.2.0"
4
+ description = "An httpx client with persistent caching and rate limiting for data pipelines"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "httpx-frugal contributors" }]
9
+ keywords = ["httpx", "cache", "rate-limit", "hishel", "pyrate-limiter"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Internet :: WWW/HTTP",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "httpx>=0.27,<1",
23
+ "hishel[async]>=1.2,<2",
24
+ "pyrate-limiter>=4,<5",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ http2 = ["httpx[http2]"]
29
+ async = ["hishel[async]"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/httpx-frugal/httpx-frugal"
33
+ Documentation = "https://github.com/httpx-frugal/httpx-frugal#readme"
34
+ Repository = "https://github.com/httpx-frugal/httpx-frugal"
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/httpx_frugal"]
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=8,<9",
46
+ "pytest-asyncio>=0.24,<1",
47
+ "ruff>=0.8,<1",
48
+ ]
49
+ publish = [
50
+ "twine>=6.2.0",
51
+ ]
52
+
53
+ [tool.ruff]
54
+ target-version = "py311"
55
+ line-length = 100
56
+ src = ["src", "tests"]
57
+ extend-exclude = ["original_source_code.py"]
58
+
59
+ [tool.ruff.lint]
60
+ select = ["E", "F", "I", "UP", "B"]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
64
+ pythonpath = ["tests"]
65
+ asyncio_mode = "auto"
66
+ asyncio_default_fixture_loop_scope = "function"