hishel 0.0.25__tar.gz → 0.0.27__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.
Files changed (29) hide show
  1. {hishel-0.0.25 → hishel-0.0.27}/CHANGELOG.md +9 -0
  2. {hishel-0.0.25 → hishel-0.0.27}/PKG-INFO +10 -1
  3. {hishel-0.0.25 → hishel-0.0.27}/hishel/__init__.py +1 -1
  4. {hishel-0.0.25 → hishel-0.0.27}/hishel/_async/_pool.py +45 -29
  5. {hishel-0.0.25 → hishel-0.0.27}/hishel/_async/_storages.py +173 -13
  6. {hishel-0.0.25 → hishel-0.0.27}/hishel/_async/_transports.py +72 -72
  7. {hishel-0.0.25 → hishel-0.0.27}/hishel/_s3.py +27 -8
  8. {hishel-0.0.25 → hishel-0.0.27}/hishel/_sync/_pool.py +45 -29
  9. {hishel-0.0.25 → hishel-0.0.27}/hishel/_sync/_storages.py +173 -13
  10. {hishel-0.0.25 → hishel-0.0.27}/hishel/_sync/_transports.py +72 -72
  11. {hishel-0.0.25 → hishel-0.0.27}/.gitignore +0 -0
  12. {hishel-0.0.25 → hishel-0.0.27}/LICENSE +0 -0
  13. {hishel-0.0.25 → hishel-0.0.27}/README.md +0 -0
  14. {hishel-0.0.25 → hishel-0.0.27}/hishel/_async/__init__.py +0 -0
  15. {hishel-0.0.25 → hishel-0.0.27}/hishel/_async/_client.py +0 -0
  16. {hishel-0.0.25 → hishel-0.0.27}/hishel/_async/_mock.py +0 -0
  17. {hishel-0.0.25 → hishel-0.0.27}/hishel/_controller.py +0 -0
  18. {hishel-0.0.25 → hishel-0.0.27}/hishel/_exceptions.py +0 -0
  19. {hishel-0.0.25 → hishel-0.0.27}/hishel/_files.py +0 -0
  20. {hishel-0.0.25 → hishel-0.0.27}/hishel/_headers.py +0 -0
  21. {hishel-0.0.25 → hishel-0.0.27}/hishel/_lfu_cache.py +0 -0
  22. {hishel-0.0.25 → hishel-0.0.27}/hishel/_serializers.py +0 -0
  23. {hishel-0.0.25 → hishel-0.0.27}/hishel/_sync/__init__.py +0 -0
  24. {hishel-0.0.25 → hishel-0.0.27}/hishel/_sync/_client.py +0 -0
  25. {hishel-0.0.25 → hishel-0.0.27}/hishel/_sync/_mock.py +0 -0
  26. {hishel-0.0.25 → hishel-0.0.27}/hishel/_synchronization.py +0 -0
  27. {hishel-0.0.25 → hishel-0.0.27}/hishel/_utils.py +0 -0
  28. {hishel-0.0.25 → hishel-0.0.27}/hishel/py.typed +0 -0
  29. {hishel-0.0.25 → hishel-0.0.27}/pyproject.toml +0 -0
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.27 (31th May, 2024)
4
+
5
+ - Fix `RedisStorage` when using without ttl. (#231)
6
+
7
+ ## 0.0.26 (12th April, 2024)
8
+
9
+ - Expose `AsyncBaseStorage` and `BaseStorage`. (#220)
10
+ - Prevent cache hits from resetting the ttl. (#215)
11
+
3
12
  ## 0.0.25 (26th March, 2024)
4
13
 
5
14
  - Add `force_cache` property to the controller, allowing RFC9111 rules to be completely disabled. (#204)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: hishel
3
- Version: 0.0.25
3
+ Version: 0.0.27
4
4
  Summary: Persistent cache implementation for httpx and httpcore
5
5
  Project-URL: Homepage, https://hishel.com
6
6
  Project-URL: Source, https://github.com/karpetrosyan/hishel
@@ -175,6 +175,15 @@ Help us grow and continue developing good software for you ❤️
175
175
 
176
176
  # Changelog
177
177
 
178
+ ## 0.0.27 (31th May, 2024)
179
+
180
+ - Fix `RedisStorage` when using without ttl. (#231)
181
+
182
+ ## 0.0.26 (12th April, 2024)
183
+
184
+ - Expose `AsyncBaseStorage` and `BaseStorage`. (#220)
185
+ - Prevent cache hits from resetting the ttl. (#215)
186
+
178
187
  ## 0.0.25 (26th March, 2024)
179
188
 
180
189
  - Add `force_cache` property to the controller, allowing RFC9111 rules to be completely disabled. (#204)
@@ -14,4 +14,4 @@ def install_cache() -> None: # pragma: no cover
14
14
  httpx.Client = CacheClient # type: ignore
15
15
 
16
16
 
17
- __version__ = "0.0.25"
17
+ __version__ = "0.0.27"
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import datetime
2
4
  import types
3
5
  import typing as tp
@@ -86,6 +88,9 @@ class AsyncCacheConnectionPool(AsyncRequestInterface):
86
88
 
87
89
  stored_response, stored_request, metadata = stored_data
88
90
 
91
+ # Immediately read the stored response to avoid issues when trying to access the response body.
92
+ stored_response.read()
93
+
89
94
  res = self._controller.construct_response_from_cache(
90
95
  request=request,
91
96
  response=stored_response,
@@ -94,55 +99,66 @@ class AsyncCacheConnectionPool(AsyncRequestInterface):
94
99
 
95
100
  if isinstance(res, Response):
96
101
  # Simply use the response if the controller determines it is ready for use.
97
- metadata["number_of_uses"] += 1
98
- stored_response.read()
99
- await self._storage.store(
100
- key=key,
101
- request=request,
102
- response=stored_response,
103
- metadata=metadata,
102
+ return await self._create_hishel_response(
103
+ key=key, response=stored_response, request=request, metadata=metadata, cached=True
104
104
  )
105
- res.extensions["from_cache"] = True # type: ignore[index]
106
- res.extensions["cache_metadata"] = metadata # type: ignore[index]
107
- return res
108
105
 
109
106
  if request_cache_control.only_if_cached:
110
107
  return generate_504()
111
108
 
112
109
  if isinstance(res, Request):
113
- # Re-validating the response.
110
+ # Controller has determined that the response needs to be re-validated.
114
111
 
115
112
  try:
116
- response = await self._pool.handle_async_request(res)
113
+ revalidation_response = await self._pool.handle_async_request(res)
117
114
  except ConnectError:
115
+ # If there is a connection error, we can use the stale response if allowed.
118
116
  if self._controller._allow_stale and allowed_stale(response=stored_response):
119
- stored_response.extensions["from_cache"] = True # type: ignore[index]
120
- stored_response.extensions["cache_metadata"] = metadata # type: ignore[index]
121
- return stored_response
117
+ return await self._create_hishel_response(
118
+ key=key, response=stored_response, request=request, metadata=metadata, cached=True
119
+ )
122
120
  raise # pragma: no cover
123
121
  # Merge headers with the stale response.
124
- full_response = self._controller.handle_validation_response(
125
- old_response=stored_response, new_response=response
122
+ final_response = self._controller.handle_validation_response(
123
+ old_response=stored_response, new_response=revalidation_response
126
124
  )
127
125
 
128
- await full_response.aread()
129
- metadata["number_of_uses"] += response.status == 304
130
- await self._storage.store(key, response=full_response, request=request, metadata=metadata)
131
- full_response.extensions["from_cache"] = response.status == 304 # type: ignore[index]
132
- if full_response.extensions["from_cache"]:
133
- full_response.extensions["cache_metadata"] = metadata # type: ignore[index]
134
- return full_response
126
+ await final_response.aread()
127
+ return await self._create_hishel_response(
128
+ key=key,
129
+ response=final_response,
130
+ request=request,
131
+ metadata=metadata,
132
+ cached=revalidation_response.status == 304,
133
+ )
135
134
 
136
- response = await self._pool.handle_async_request(request)
135
+ regular_response = await self._pool.handle_async_request(request)
136
+ await regular_response.aread()
137
137
 
138
- if self._controller.is_cachable(request=request, response=response):
139
- await response.aread()
138
+ if self._controller.is_cachable(request=request, response=regular_response):
140
139
  metadata = Metadata(
141
140
  cache_key=key, created_at=datetime.datetime.now(datetime.timezone.utc), number_of_uses=0
142
141
  )
143
- await self._storage.store(key, response=response, request=request, metadata=metadata)
142
+ await self._storage.store(key, response=regular_response, request=request, metadata=metadata)
144
143
 
145
- response.extensions["from_cache"] = False # type: ignore[index]
144
+ return await self._create_hishel_response(key=key, response=regular_response, request=request, cached=False)
145
+
146
+ async def _create_hishel_response(
147
+ self,
148
+ key: str,
149
+ response: Response,
150
+ request: Request,
151
+ cached: bool,
152
+ metadata: Metadata | None = None,
153
+ ) -> Response:
154
+ if cached:
155
+ assert metadata
156
+ metadata["number_of_uses"] += 1
157
+ await self._storage.update_metadata(key=key, request=request, response=response, metadata=metadata)
158
+ response.extensions["from_cache"] = True # type: ignore[index]
159
+ response.extensions["cache_metadata"] = metadata # type: ignore[index]
160
+ else:
161
+ response.extensions["from_cache"] = False # type: ignore[index]
146
162
  return response
147
163
 
148
164
  async def aclose(self) -> None:
@@ -1,4 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
1
4
  import logging
5
+ import os
2
6
  import time
3
7
  import typing as tp
4
8
  import warnings
@@ -29,7 +33,14 @@ from .._utils import float_seconds_to_int_milliseconds
29
33
 
30
34
  logger = logging.getLogger("hishel.storages")
31
35
 
32
- __all__ = ("AsyncFileStorage", "AsyncRedisStorage", "AsyncSQLiteStorage", "AsyncInMemoryStorage", "AsyncS3Storage")
36
+ __all__ = (
37
+ "AsyncBaseStorage",
38
+ "AsyncFileStorage",
39
+ "AsyncRedisStorage",
40
+ "AsyncSQLiteStorage",
41
+ "AsyncInMemoryStorage",
42
+ "AsyncS3Storage",
43
+ )
33
44
 
34
45
  StoredResponse: TypeAlias = tp.Tuple[Response, Request, Metadata]
35
46
 
@@ -48,7 +59,10 @@ class AsyncBaseStorage:
48
59
  self._serializer = serializer or JSONSerializer()
49
60
  self._ttl = ttl
50
61
 
51
- async def store(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
62
+ async def store(self, key: str, response: Response, request: Request, metadata: Metadata | None = None) -> None:
63
+ raise NotImplementedError()
64
+
65
+ async def update_metadata(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
52
66
  raise NotImplementedError()
53
67
 
54
68
  async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
@@ -97,7 +111,7 @@ class AsyncFileStorage(AsyncBaseStorage):
97
111
  self._check_ttl_every = check_ttl_every
98
112
  self._last_cleaned = time.monotonic()
99
113
 
100
- async def store(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
114
+ async def store(self, key: str, response: Response, request: Request, metadata: Metadata | None = None) -> None:
101
115
  """
102
116
  Stores the response in the cache.
103
117
 
@@ -108,8 +122,12 @@ class AsyncFileStorage(AsyncBaseStorage):
108
122
  :param request: An HTTP request
109
123
  :type request: httpcore.Request
110
124
  :param metadata: Additional information about the stored response
111
- :type metadata: Metadata
125
+ :type metadata: Optional[Metadata]
112
126
  """
127
+
128
+ metadata = metadata or Metadata(
129
+ cache_key=key, created_at=datetime.datetime.now(datetime.timezone.utc), number_of_uses=0
130
+ )
113
131
  response_path = self._base_path / key
114
132
 
115
133
  async with self._lock:
@@ -119,6 +137,36 @@ class AsyncFileStorage(AsyncBaseStorage):
119
137
  )
120
138
  await self._remove_expired_caches(response_path)
121
139
 
140
+ async def update_metadata(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
141
+ """
142
+ Updates the metadata of the stored response.
143
+
144
+ :param key: Hashed value of concatenated HTTP method and URI
145
+ :type key: str
146
+ :param response: An HTTP response
147
+ :type response: httpcore.Response
148
+ :param request: An HTTP request
149
+ :type request: httpcore.Request
150
+ :param metadata: Additional information about the stored response
151
+ :type metadata: Metadata
152
+ """
153
+ response_path = self._base_path / key
154
+
155
+ async with self._lock:
156
+ if response_path.exists():
157
+ atime = response_path.stat().st_atime
158
+ old_mtime = response_path.stat().st_mtime
159
+ await self._file_manager.write_to(
160
+ str(response_path),
161
+ self._serializer.dumps(response=response, request=request, metadata=metadata),
162
+ )
163
+
164
+ # Restore the old atime and mtime (we use mtime to check the cache expiration time)
165
+ os.utime(response_path, (atime, old_mtime))
166
+ return
167
+
168
+ return await self.store(key, response, request, metadata) # pragma: no cover
169
+
122
170
  async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
123
171
  """
124
172
  Retreives the response from the cache using his key.
@@ -206,7 +254,7 @@ class AsyncSQLiteStorage(AsyncBaseStorage):
206
254
  await self._connection.commit()
207
255
  self._setup_completed = True
208
256
 
209
- async def store(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
257
+ async def store(self, key: str, response: Response, request: Request, metadata: Metadata | None = None) -> None:
210
258
  """
211
259
  Stores the response in the cache.
212
260
 
@@ -217,12 +265,16 @@ class AsyncSQLiteStorage(AsyncBaseStorage):
217
265
  :param request: An HTTP request
218
266
  :type request: httpcore.Request
219
267
  :param metadata: Additioal information about the stored response
220
- :type metadata: Metadata
268
+ :type metadata: Optional[Metadata]
221
269
  """
222
270
 
223
271
  await self._setup()
224
272
  assert self._connection
225
273
 
274
+ metadata = metadata or Metadata(
275
+ cache_key=key, created_at=datetime.datetime.now(datetime.timezone.utc), number_of_uses=0
276
+ )
277
+
226
278
  async with self._lock:
227
279
  await self._connection.execute("DELETE FROM cache WHERE key = ?", [key])
228
280
  serialized_response = self._serializer.dumps(response=response, request=request, metadata=metadata)
@@ -232,6 +284,33 @@ class AsyncSQLiteStorage(AsyncBaseStorage):
232
284
  await self._connection.commit()
233
285
  await self._remove_expired_caches()
234
286
 
287
+ async def update_metadata(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
288
+ """
289
+ Updates the metadata of the stored response.
290
+
291
+ :param key: Hashed value of concatenated HTTP method and URI
292
+ :type key: str
293
+ :param response: An HTTP response
294
+ :type response: httpcore.Response
295
+ :param request: An HTTP request
296
+ :type request: httpcore.Request
297
+ :param metadata: Additional information about the stored response
298
+ :type metadata: Metadata
299
+ """
300
+
301
+ await self._setup()
302
+ assert self._connection
303
+
304
+ async with self._lock:
305
+ cursor = await self._connection.execute("SELECT data FROM cache WHERE key = ?", [key])
306
+ row = await cursor.fetchone()
307
+ if row is not None:
308
+ serialized_response = self._serializer.dumps(response=response, request=request, metadata=metadata)
309
+ await self._connection.execute("UPDATE cache SET data = ? WHERE key = ?", [serialized_response, key])
310
+ await self._connection.commit()
311
+ return
312
+ return await self.store(key, response, request, metadata) # pragma: no cover
313
+
235
314
  async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
236
315
  """
237
316
  Retreives the response from the cache using his key.
@@ -302,7 +381,7 @@ class AsyncRedisStorage(AsyncBaseStorage):
302
381
  else: # pragma: no cover
303
382
  self._client = client
304
383
 
305
- async def store(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
384
+ async def store(self, key: str, response: Response, request: Request, metadata: Metadata | None = None) -> None:
306
385
  """
307
386
  Stores the response in the cache.
308
387
 
@@ -313,9 +392,13 @@ class AsyncRedisStorage(AsyncBaseStorage):
313
392
  :param request: An HTTP request
314
393
  :type request: httpcore.Request
315
394
  :param metadata: Additioal information about the stored response
316
- :type metadata: Metadata
395
+ :type metadata: Optional[Metadata]
317
396
  """
318
397
 
398
+ metadata = metadata or Metadata(
399
+ cache_key=key, created_at=datetime.datetime.now(datetime.timezone.utc), number_of_uses=0
400
+ )
401
+
319
402
  if self._ttl is not None:
320
403
  px = float_seconds_to_int_milliseconds(self._ttl)
321
404
  else:
@@ -325,6 +408,33 @@ class AsyncRedisStorage(AsyncBaseStorage):
325
408
  key, self._serializer.dumps(response=response, request=request, metadata=metadata), px=px
326
409
  )
327
410
 
411
+ async def update_metadata(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
412
+ """
413
+ Updates the metadata of the stored response.
414
+
415
+ :param key: Hashed value of concatenated HTTP method and URI
416
+ :type key: str
417
+ :param response: An HTTP response
418
+ :type response: httpcore.Response
419
+ :param request: An HTTP request
420
+ :type request: httpcore.Request
421
+ :param metadata: Additional information about the stored response
422
+ :type metadata: Metadata
423
+ """
424
+
425
+ ttl_in_milliseconds = await self._client.pttl(key)
426
+
427
+ # -2: if the key does not exist in Redis
428
+ # -1: if the key exists in Redis but has no expiration
429
+ if ttl_in_milliseconds == -2 or ttl_in_milliseconds == -1: # pragma: no cover
430
+ await self.store(key, response, request, metadata)
431
+ else:
432
+ await self._client.set(
433
+ key,
434
+ self._serializer.dumps(response=response, request=request, metadata=metadata),
435
+ px=ttl_in_milliseconds,
436
+ )
437
+
328
438
  async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
329
439
  """
330
440
  Retreives the response from the cache using his key.
@@ -373,7 +483,7 @@ class AsyncInMemoryStorage(AsyncBaseStorage):
373
483
  self._cache: LFUCache[str, tp.Tuple[StoredResponse, float]] = LFUCache(capacity=capacity)
374
484
  self._lock = AsyncLock()
375
485
 
376
- async def store(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
486
+ async def store(self, key: str, response: Response, request: Request, metadata: Metadata | None = None) -> None:
377
487
  """
378
488
  Stores the response in the cache.
379
489
 
@@ -384,9 +494,13 @@ class AsyncInMemoryStorage(AsyncBaseStorage):
384
494
  :param request: An HTTP request
385
495
  :type request: httpcore.Request
386
496
  :param metadata: Additioal information about the stored response
387
- :type metadata: Metadata
497
+ :type metadata: Optional[Metadata]
388
498
  """
389
499
 
500
+ metadata = metadata or Metadata(
501
+ cache_key=key, created_at=datetime.datetime.now(datetime.timezone.utc), number_of_uses=0
502
+ )
503
+
390
504
  async with self._lock:
391
505
  response_clone = clone_model(response)
392
506
  request_clone = clone_model(request)
@@ -394,6 +508,30 @@ class AsyncInMemoryStorage(AsyncBaseStorage):
394
508
  self._cache.put(key, (stored_response, time.monotonic()))
395
509
  await self._remove_expired_caches()
396
510
 
511
+ async def update_metadata(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
512
+ """
513
+ Updates the metadata of the stored response.
514
+
515
+ :param key: Hashed value of concatenated HTTP method and URI
516
+ :type key: str
517
+ :param response: An HTTP response
518
+ :type response: httpcore.Response
519
+ :param request: An HTTP request
520
+ :type request: httpcore.Request
521
+ :param metadata: Additional information about the stored response
522
+ :type metadata: Metadata
523
+ """
524
+
525
+ async with self._lock:
526
+ try:
527
+ stored_response, created_at = self._cache.get(key)
528
+ stored_response = (stored_response[0], stored_response[1], metadata)
529
+ self._cache.put(key, (stored_response, created_at))
530
+ return
531
+ except KeyError: # pragma: no cover
532
+ pass
533
+ await self.store(key, response, request, metadata) # pragma: no cover
534
+
397
535
  async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
398
536
  """
399
537
  Retreives the response from the cache using his key.
@@ -432,7 +570,7 @@ class AsyncInMemoryStorage(AsyncBaseStorage):
432
570
  self._cache.remove_key(key)
433
571
 
434
572
 
435
- class AsyncS3Storage(AsyncBaseStorage):
573
+ class AsyncS3Storage(AsyncBaseStorage): # pragma: no cover
436
574
  """
437
575
  AWS S3 storage.
438
576
 
@@ -478,7 +616,7 @@ class AsyncS3Storage(AsyncBaseStorage):
478
616
  )
479
617
  self._lock = AsyncLock()
480
618
 
481
- async def store(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
619
+ async def store(self, key: str, response: Response, request: Request, metadata: Metadata | None = None) -> None:
482
620
  """
483
621
  Stores the response in the cache.
484
622
 
@@ -489,15 +627,37 @@ class AsyncS3Storage(AsyncBaseStorage):
489
627
  :param request: An HTTP request
490
628
  :type request: httpcore.Request
491
629
  :param metadata: Additioal information about the stored response
492
- :type metadata: Metadata`
630
+ :type metadata: Optional[Metadata]`
493
631
  """
494
632
 
633
+ metadata = metadata or Metadata(
634
+ cache_key=key, created_at=datetime.datetime.now(datetime.timezone.utc), number_of_uses=0
635
+ )
636
+
495
637
  async with self._lock:
496
638
  serialized = self._serializer.dumps(response=response, request=request, metadata=metadata)
497
639
  await self._s3_manager.write_to(path=key, data=serialized)
498
640
 
499
641
  await self._remove_expired_caches(key)
500
642
 
643
+ async def update_metadata(self, key: str, response: Response, request: Request, metadata: Metadata) -> None:
644
+ """
645
+ Updates the metadata of the stored response.
646
+
647
+ :param key: Hashed value of concatenated HTTP method and URI
648
+ :type key: str
649
+ :param response: An HTTP response
650
+ :type response: httpcore.Response
651
+ :param request: An HTTP request
652
+ :type request: httpcore.Request
653
+ :param metadata: Additional information about the stored response
654
+ :type metadata: Metadata
655
+ """
656
+
657
+ async with self._lock:
658
+ serialized = self._serializer.dumps(response=response, request=request, metadata=metadata)
659
+ await self._s3_manager.write_to(path=key, data=serialized, only_metadata=True)
660
+
501
661
  async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
502
662
  """
503
663
  Retreives the response from the cache using his key.