hishel 1.0.0.dev1__tar.gz → 1.0.0.dev2__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 (22) hide show
  1. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/CHANGELOG.md +39 -21
  2. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/PKG-INFO +40 -22
  3. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/_async_cache.py +9 -2
  4. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/_core/_spec.py +120 -67
  5. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/_core/models.py +7 -4
  6. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/_sync_cache.py +9 -2
  7. hishel-1.0.0.dev2/hishel/_utils.py +82 -0
  8. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/pyproject.toml +1 -1
  9. hishel-1.0.0.dev1/hishel/_utils.py +0 -218
  10. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/.gitignore +0 -0
  11. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/LICENSE +0 -0
  12. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/README.md +0 -0
  13. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/__init__.py +0 -0
  14. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/_core/__init__.py +0 -0
  15. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/_core/_async/_storages/_sqlite.py +0 -0
  16. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/_core/_base/_storages/_base.py +0 -0
  17. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/_core/_base/_storages/_packing.py +0 -0
  18. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/_core/_headers.py +0 -0
  19. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/_core/_sync/_storages/_sqlite.py +0 -0
  20. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/httpx.py +0 -0
  21. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/py.typed +0 -0
  22. {hishel-1.0.0.dev1 → hishel-1.0.0.dev2}/hishel/requests.py +0 -0
@@ -2,44 +2,62 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## 1.0.0dev1 - 2025-10-21
6
- ### <!-- 7 -->⚙️ Miscellaneous Tasks
5
+ ## 1.0.0.dev2 - 2025-10-21
6
+ ### ⚙️ Miscellaneous Tasks
7
+ - Remove redundant utils and tests
8
+ - Add import without extras check in ci
9
+
10
+ ### 🐛 Bug Fixes
11
+ - Fix check for storing auth requests
12
+ - Don't raise an error on 3xx during revalidation
13
+
14
+ ### 🚀 Features
15
+ - Add hishel_created_at response metadata
16
+
17
+ ## 1.0.0.dev1 - 2025-10-21
18
+ ### ⚙️ Miscellaneous Tasks
7
19
  - Remove some redundant utils methods
8
20
 
21
+ ### 📦 Dependencies
22
+ - Make httpx and async libs optional dependencies
23
+ - Make `anysqlite` optional dependency
24
+ - Install async extra with httpx
25
+ - Improve git-cliff
26
+
9
27
  ## 1.0.0.dev0 - 2025-10-19
10
- ### <!-- 7 -->⚙️ Miscellaneous Tasks
28
+ ### ⚙️ Miscellaneous Tasks
11
29
  - Use mike powered versioning
12
30
  - Improve docs versioning, deploy dev doc on ci
13
31
 
14
32
  ## 0.1.5 - 2025-10-18
15
- ### <!-- 0 -->🚀 Features
33
+ ### ⚙️ Miscellaneous Tasks
34
+ - Remove some redundant files from repo
35
+
36
+ ### 🐛 Bug Fixes
37
+ - Fix some line breaks
38
+
39
+ ### 🚀 Features
16
40
  - Set chunk size to 128KB for httpx to reduce SQLite read/writes
17
41
  - Better cache-control parsing
18
42
  - Add close method to storages API (#384)
19
43
  - Increase requests buffer size to 128KB, disable charset detection
20
44
 
21
- ### <!-- 1 -->🐛 Bug Fixes
22
- - Fix some line breaks
45
+ ## 0.1.4 - 2025-10-14
46
+ ### ⚙️ Miscellaneous Tasks
47
+ - Improve CI (#369)
48
+ - Remove src folder (#373)
49
+ - Temporary remove python3.14 from CI
50
+ - Add sqlite tests for new storage
51
+ - Move some tests to beta
23
52
 
24
- ### <!-- 7 -->⚙️ Miscellaneous Tasks
25
- - Remove some redundant files from repo
53
+ ### 🐛 Bug Fixes
54
+ - Create an sqlite file in a cache folder
55
+ - Fix beta imports
26
56
 
27
- ## 0.1.4 - 2025-10-14
28
- ### <!-- 0 -->🚀 Features
57
+ ### 🚀 Features
29
58
  - Add support for a sans-IO API (#366)
30
59
  - Allow already consumed streams with `CacheTransport` (#377)
31
60
  - Add sqlite storage for beta storages
32
61
  - Get rid of some locks from sqlite storage
33
62
  - Better async implemetation for sqlite storage
34
63
 
35
- ### <!-- 1 -->🐛 Bug Fixes
36
- - Create an sqlite file in a cache folder
37
- - Fix beta imports
38
-
39
- ### <!-- 7 -->⚙️ Miscellaneous Tasks
40
- - Improve CI (#369)
41
- - Remove src folder (#373)
42
- - Temporary remove python3.14 from CI
43
- - Add sqlite tests for new storage
44
- - Move some tests to beta
45
-
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hishel
3
- Version: 1.0.0.dev1
3
+ Version: 1.0.0.dev2
4
4
  Summary: Elegant HTTP Caching for Python
5
5
  Project-URL: Homepage, https://hishel.com
6
6
  Project-URL: Source, https://github.com/karpetrosyan/hishel
@@ -255,44 +255,62 @@ Hishel is inspired by and builds upon the excellent work in the Python HTTP ecos
255
255
 
256
256
  All notable changes to this project will be documented in this file.
257
257
 
258
- ## 1.0.0dev1 - 2025-10-21
259
- ### <!-- 7 -->⚙️ Miscellaneous Tasks
258
+ ## 1.0.0.dev2 - 2025-10-21
259
+ ### ⚙️ Miscellaneous Tasks
260
+ - Remove redundant utils and tests
261
+ - Add import without extras check in ci
262
+
263
+ ### 🐛 Bug Fixes
264
+ - Fix check for storing auth requests
265
+ - Don't raise an error on 3xx during revalidation
266
+
267
+ ### 🚀 Features
268
+ - Add hishel_created_at response metadata
269
+
270
+ ## 1.0.0.dev1 - 2025-10-21
271
+ ### ⚙️ Miscellaneous Tasks
260
272
  - Remove some redundant utils methods
261
273
 
274
+ ### 📦 Dependencies
275
+ - Make httpx and async libs optional dependencies
276
+ - Make `anysqlite` optional dependency
277
+ - Install async extra with httpx
278
+ - Improve git-cliff
279
+
262
280
  ## 1.0.0.dev0 - 2025-10-19
263
- ### <!-- 7 -->⚙️ Miscellaneous Tasks
281
+ ### ⚙️ Miscellaneous Tasks
264
282
  - Use mike powered versioning
265
283
  - Improve docs versioning, deploy dev doc on ci
266
284
 
267
285
  ## 0.1.5 - 2025-10-18
268
- ### <!-- 0 -->🚀 Features
286
+ ### ⚙️ Miscellaneous Tasks
287
+ - Remove some redundant files from repo
288
+
289
+ ### 🐛 Bug Fixes
290
+ - Fix some line breaks
291
+
292
+ ### 🚀 Features
269
293
  - Set chunk size to 128KB for httpx to reduce SQLite read/writes
270
294
  - Better cache-control parsing
271
295
  - Add close method to storages API (#384)
272
296
  - Increase requests buffer size to 128KB, disable charset detection
273
297
 
274
- ### <!-- 1 -->🐛 Bug Fixes
275
- - Fix some line breaks
298
+ ## 0.1.4 - 2025-10-14
299
+ ### ⚙️ Miscellaneous Tasks
300
+ - Improve CI (#369)
301
+ - Remove src folder (#373)
302
+ - Temporary remove python3.14 from CI
303
+ - Add sqlite tests for new storage
304
+ - Move some tests to beta
276
305
 
277
- ### <!-- 7 -->⚙️ Miscellaneous Tasks
278
- - Remove some redundant files from repo
306
+ ### 🐛 Bug Fixes
307
+ - Create an sqlite file in a cache folder
308
+ - Fix beta imports
279
309
 
280
- ## 0.1.4 - 2025-10-14
281
- ### <!-- 0 -->🚀 Features
310
+ ### 🚀 Features
282
311
  - Add support for a sans-IO API (#366)
283
312
  - Allow already consumed streams with `CacheTransport` (#377)
284
313
  - Add sqlite storage for beta storages
285
314
  - Get rid of some locks from sqlite storage
286
315
  - Better async implemetation for sqlite storage
287
316
 
288
- ### <!-- 1 -->🐛 Bug Fixes
289
- - Create an sqlite file in a cache folder
290
- - Fix beta imports
291
-
292
- ### <!-- 7 -->⚙️ Miscellaneous Tasks
293
- - Improve CI (#369)
294
- - Remove src folder (#373)
295
- - Temporary remove python3.14 from CI
296
- - Add sqlite tests for new storage
297
- - Move some tests to beta
298
-
@@ -25,7 +25,7 @@ from hishel import (
25
25
  create_idle_state,
26
26
  )
27
27
  from hishel._core._spec import InvalidatePairs, vary_headers_match
28
- from hishel._core.models import CompletePair
28
+ from hishel._core.models import CompletePair, ResponseMetadata
29
29
 
30
30
  logger = logging.getLogger("hishel.integrations.clients")
31
31
 
@@ -90,7 +90,14 @@ class AsyncCacheProxy:
90
90
  logger.debug(
91
91
  "Found matching cached response for the request",
92
92
  )
93
- pair.response.metadata["hishel_from_cache"] = True # type: ignore
93
+ response_meta = ResponseMetadata(
94
+ hishel_spec_ignored=True,
95
+ hishel_from_cache=True,
96
+ hishel_created_at=pair.meta.created_at,
97
+ hishel_revalidated=False,
98
+ hishel_stored=False,
99
+ )
100
+ pair.response.metadata.update(response_meta) # type: ignore
94
101
  await self._maybe_refresh_pair_ttl(pair)
95
102
  return pair.response
96
103
 
@@ -16,6 +16,7 @@ from typing import (
16
16
  )
17
17
 
18
18
  from hishel._core._headers import Headers, Range, Vary, parse_cache_control
19
+ from hishel._core.models import ResponseMetadata
19
20
  from hishel._utils import parse_date, partition
20
21
 
21
22
  if TYPE_CHECKING:
@@ -1412,16 +1413,11 @@ class IdleClient(State):
1412
1413
  #
1413
1414
  # The Age header informs the client how old the cached response is.
1414
1415
 
1415
- # Mark all ready-to-use responses with metadata (for observability)
1416
- for pair in ready_to_use:
1417
- pair.response.metadata["hishel_from_cache"] = True # type: ignore
1418
-
1419
1416
  # Use the most recent response (first in sorted list)
1420
1417
  selected_pair = ready_to_use[0]
1421
1418
 
1422
1419
  # Calculate current age and update the Age header
1423
1420
  current_age = get_age(selected_pair.response)
1424
-
1425
1421
  return FromCache(
1426
1422
  pair=replace(
1427
1423
  selected_pair,
@@ -1578,20 +1574,6 @@ class CacheMiss(State):
1578
1574
  * an s-maxage response directive (if cache is shared)
1579
1575
  * a status code that is defined as heuristically cacheable"
1580
1576
 
1581
- Side Effects:
1582
- ------------
1583
- Sets metadata flags on the response object:
1584
- - hishel_spec_ignored: False (caching spec is being followed)
1585
- - hishel_from_cache: False (response is from origin, not cache)
1586
- - hishel_revalidated: True (if after_revalidation is True)
1587
- - hishel_stored: True/False (whether response was stored)
1588
-
1589
- Logging:
1590
- -------
1591
- When a response cannot be stored, detailed debug logs are emitted explaining
1592
- which specific RFC requirement failed, with direct links to the relevant
1593
- RFC sections.
1594
-
1595
1577
  Examples:
1596
1578
  --------
1597
1579
  >>> # Cacheable response
@@ -1614,21 +1596,6 @@ class CacheMiss(State):
1614
1596
  True
1615
1597
  """
1616
1598
 
1617
- # ============================================================================
1618
- # STEP 1: Set Response Metadata
1619
- # ============================================================================
1620
- # Initialize metadata flags to track the response lifecycle
1621
-
1622
- response.metadata["hishel_spec_ignored"] = False # type: ignore
1623
- # We are following the caching specification
1624
-
1625
- response.metadata["hishel_from_cache"] = False # type: ignore
1626
- # This response came from origin server, not cache
1627
-
1628
- if self.after_revalidation:
1629
- response.metadata["hishel_revalidated"] = True # type: ignore
1630
- # Mark that this response is the result of a revalidation
1631
-
1632
1599
  # ============================================================================
1633
1600
  # STEP 2: Parse Cache-Control Directive
1634
1601
  # ============================================================================
@@ -1723,11 +1690,14 @@ class CacheMiss(State):
1723
1690
  #
1724
1691
  # Requests with Authorization headers often contain user-specific data.
1725
1692
  # Shared caches must be careful not to serve one user's data to another.
1726
- #
1727
- # This check is inverted in the current implementation and needs review:
1728
- # TODO: Fix logic - should be: (not shared) OR (no auth header) OR (has explicit directive)
1729
- # Current logic: (shared) AND (no auth header)
1730
- is_shared_and_authorized = not (self.options.shared and "authorization" in request.headers)
1693
+ has_explicit_directive = (
1694
+ response_cache_control.public
1695
+ or response_cache_control.s_maxage is not None
1696
+ or response_cache_control.must_revalidate
1697
+ )
1698
+ can_cache_auth_request = (
1699
+ not self.options.shared or "authorization" not in request.headers or has_explicit_directive
1700
+ )
1731
1701
 
1732
1702
  # CONDITION 7: Response Contains Required Caching Information
1733
1703
  # RFC 9111 Section 3, paragraph 2.7:
@@ -1797,7 +1767,7 @@ class CacheMiss(State):
1797
1767
  or not understands_how_to_cache
1798
1768
  or not no_store_is_not_present
1799
1769
  or not private_directive_allows_storing
1800
- or not is_shared_and_authorized
1770
+ or not can_cache_auth_request
1801
1771
  or not contains_required_component
1802
1772
  ):
1803
1773
  # --------------------------------------------------------------------
@@ -1833,10 +1803,11 @@ class CacheMiss(State):
1833
1803
  "Cannot store the response because the `private` response directive does not "
1834
1804
  "allow shared caches to store it. See: https://www.rfc-editor.org/rfc/rfc9111.html#section-3-2.5.1"
1835
1805
  )
1836
- elif not is_shared_and_authorized:
1806
+ elif not can_cache_auth_request:
1837
1807
  logger.debug(
1838
- "Cannot store the response because the cache is shared and the request contains "
1839
- "an Authorization header field. See: https://www.rfc-editor.org/rfc/rfc9111.html#section-3-2.6.1"
1808
+ "Cannot store the response because the request contained an Authorization header "
1809
+ "and there was no explicit directive allowing shared caching. "
1810
+ "See: https://www.rfc-editor.org/rfc/rfc9111.html#section-3-5"
1840
1811
  )
1841
1812
  elif not contains_required_component:
1842
1813
  logger.debug(
@@ -1844,10 +1815,9 @@ class CacheMiss(State):
1844
1815
  "See: https://www.rfc-editor.org/rfc/rfc9111.html#section-3-2.7.1"
1845
1816
  )
1846
1817
 
1847
- # Mark response as not stored
1848
- response.metadata["hishel_stored"] = False # type: ignore
1849
-
1850
- return CouldNotBeStored(response=response, pair_id=pair_id, options=self.options)
1818
+ return CouldNotBeStored(
1819
+ response=response, pair_id=pair_id, options=self.options, after_revalidation=self.after_revalidation
1820
+ )
1851
1821
 
1852
1822
  # --------------------------------------------------------------------
1853
1823
  # Transition to: StoreAndUse
@@ -1856,9 +1826,6 @@ class CacheMiss(State):
1856
1826
 
1857
1827
  logger.debug("Storing response in cache")
1858
1828
 
1859
- # Mark response as stored
1860
- response.metadata["hishel_stored"] = True # type: ignore
1861
-
1862
1829
  # Remove headers that should not be stored
1863
1830
  # RFC 9111 Section 3.1: Storing Header and Trailer Fields
1864
1831
  # https://www.rfc-editor.org/rfc/rfc9111.html#section-3.1
@@ -1869,6 +1836,7 @@ class CacheMiss(State):
1869
1836
  pair_id=pair_id,
1870
1837
  response=cleaned_response,
1871
1838
  options=self.options,
1839
+ after_revalidation=self.after_revalidation,
1872
1840
  )
1873
1841
 
1874
1842
 
@@ -1929,7 +1897,9 @@ class NeedRevalidation(State):
1929
1897
  The stored pairs that the request was sent for revalidation.
1930
1898
  """
1931
1899
 
1932
- def next(self, revalidation_response: Response) -> Union["NeedToBeUpdated", "InvalidatePairs", "CacheMiss"]:
1900
+ def next(
1901
+ self, revalidation_response: Response
1902
+ ) -> Union["NeedToBeUpdated", "InvalidatePairs", "CacheMiss", "FromCache"]:
1933
1903
  """
1934
1904
  Handles the response to a conditional request and determines the next state.
1935
1905
 
@@ -2084,6 +2054,15 @@ class NeedRevalidation(State):
2084
2054
  after_revalidation=True,
2085
2055
  ).next(revalidation_response, pair_id=self.revalidating_pairs[-1].id),
2086
2056
  )
2057
+ elif revalidation_response.status_code // 100 == 3:
2058
+ # 3xx Redirects should have been followed by the HTTP client
2059
+ return FromCache(
2060
+ pair=replace(
2061
+ self.revalidating_pairs[-1],
2062
+ response=revalidation_response,
2063
+ ),
2064
+ options=self.options,
2065
+ )
2087
2066
 
2088
2067
  # ============================================================================
2089
2068
  # STEP 4: Handle Unexpected Status Codes
@@ -2316,32 +2295,99 @@ class NeedRevalidation(State):
2316
2295
  return next_state
2317
2296
 
2318
2297
 
2319
- @dataclass
2298
+ # @dataclass
2299
+ # class StoreAndUse(State):
2300
+ # """
2301
+ # The state that indicates that the response can be stored in the cache and used.
2302
+ # """
2303
+
2304
+ # pair_id: uuid.UUID
2305
+
2306
+ # response: Response
2307
+
2308
+ # def next(self) -> None:
2309
+ # return None # pragma: nocover
2310
+
2311
+
2320
2312
  class StoreAndUse(State):
2321
2313
  """
2322
2314
  The state that indicates that the response can be stored in the cache and used.
2323
- """
2324
2315
 
2325
- pair_id: uuid.UUID
2316
+ Attributes:
2317
+ ----------
2318
+ pair_id : uuid.UUID
2319
+ The unique identifier for the cache pair.
2320
+ response : Response
2321
+ The HTTP response to be stored in the cache.
2322
+ after_revalidation : bool
2323
+ Indicates if the storage is occurring after a revalidation process.
2324
+ """
2326
2325
 
2327
- response: Response
2326
+ def __init__(
2327
+ self, pair_id: uuid.UUID, response: Response, options: CacheOptions, after_revalidation: bool = False
2328
+ ) -> None:
2329
+ super().__init__(options)
2330
+ self.pair_id = pair_id
2331
+ self.response = response
2332
+ self.after_revalidation = after_revalidation
2333
+ response_meta = ResponseMetadata(
2334
+ hishel_created_at=time.time(),
2335
+ hishel_from_cache=False,
2336
+ hishel_spec_ignored=False,
2337
+ hishel_revalidated=after_revalidation,
2338
+ hishel_stored=True,
2339
+ )
2340
+ self.response.metadata.update(response_meta) # type: ignore
2328
2341
 
2329
2342
  def next(self) -> None:
2330
- return None # pragma: nocover
2343
+ return None
2344
+
2345
+
2346
+ # @dataclass
2347
+ # class CouldNotBeStored(State):
2348
+ # """
2349
+ # The state that indicates that the response could not be stored in the cache.
2350
+ # """
2351
+
2352
+ # response: Response
2353
+
2354
+ # pair_id: uuid.UUID
2355
+
2356
+ # def next(self) -> None:
2357
+ # return None # pragma: nocover
2331
2358
 
2332
2359
 
2333
- @dataclass
2334
2360
  class CouldNotBeStored(State):
2335
2361
  """
2336
2362
  The state that indicates that the response could not be stored in the cache.
2337
- """
2338
2363
 
2339
- response: Response
2364
+ Attributes:
2365
+ ----------
2366
+ response : Response
2367
+ The HTTP response that could not be stored.
2368
+ pair_id : uuid.UUID
2369
+ The unique identifier for the cache pair.
2370
+ after_revalidation : bool
2371
+ Indicates if the storage attempt occurred after a revalidation process.
2372
+ """
2340
2373
 
2341
- pair_id: uuid.UUID
2374
+ def __init__(
2375
+ self, response: Response, pair_id: uuid.UUID, options: CacheOptions, after_revalidation: bool = False
2376
+ ) -> None:
2377
+ super().__init__(options)
2378
+ self.response = response
2379
+ self.pair_id = pair_id
2380
+ response_meta = ResponseMetadata(
2381
+ hishel_created_at=time.time(),
2382
+ hishel_from_cache=False,
2383
+ hishel_spec_ignored=False,
2384
+ hishel_revalidated=after_revalidation,
2385
+ hishel_stored=False,
2386
+ )
2387
+ self.response.metadata.update(response_meta) # type: ignore
2342
2388
 
2343
2389
  def next(self) -> None:
2344
- return None # pragma: nocover
2390
+ return None
2345
2391
 
2346
2392
 
2347
2393
  @dataclass
@@ -2358,15 +2404,22 @@ class InvalidatePairs(State):
2358
2404
  return self.next_state
2359
2405
 
2360
2406
 
2361
- @dataclass
2362
2407
  class FromCache(State):
2363
- pair: CompletePair
2364
- """
2365
- List of pairs that can be used to satisfy the request.
2366
- """
2408
+ def __init__(self, pair: CompletePair, options: CacheOptions, after_revalidation: bool = False) -> None:
2409
+ super().__init__(options)
2410
+ self.pair = pair
2411
+ self.after_revalidation = after_revalidation
2412
+ response_meta = ResponseMetadata(
2413
+ hishel_created_at=pair.meta.created_at,
2414
+ hishel_from_cache=True,
2415
+ hishel_spec_ignored=False,
2416
+ hishel_revalidated=after_revalidation,
2417
+ hishel_stored=False,
2418
+ )
2419
+ self.pair.response.metadata.update(response_meta) # type: ignore
2367
2420
 
2368
2421
  def next(self) -> None:
2369
- return None # pragma: nocover
2422
+ return None
2370
2423
 
2371
2424
 
2372
2425
  @dataclass
@@ -109,18 +109,21 @@ class Request:
109
109
 
110
110
  class ResponseMetadata(TypedDict, total=False):
111
111
  # All the names here should be prefixed with "hishel_" to avoid collisions with user data
112
- hishel_from_cache: bool | None
112
+ hishel_from_cache: bool
113
113
  """Indicates whether the response was served from cache."""
114
114
 
115
- hishel_revalidated: bool | None
115
+ hishel_revalidated: bool
116
116
  """Indicates whether the response was revalidated with the origin server."""
117
117
 
118
- hishel_spec_ignored: bool | None
118
+ hishel_spec_ignored: bool
119
119
  """Indicates whether the caching specification was ignored for this response."""
120
120
 
121
- hishel_stored: bool | None
121
+ hishel_stored: bool
122
122
  """Indicates whether the response was stored in cache."""
123
123
 
124
+ hishel_created_at: float
125
+ """Timestamp when the response was cached."""
126
+
124
127
 
125
128
  @dataclass
126
129
  class Response:
@@ -25,7 +25,7 @@ from hishel import (
25
25
  create_idle_state,
26
26
  )
27
27
  from hishel._core._spec import InvalidatePairs, vary_headers_match
28
- from hishel._core.models import CompletePair
28
+ from hishel._core.models import CompletePair, ResponseMetadata
29
29
 
30
30
  logger = logging.getLogger("hishel.integrations.clients")
31
31
 
@@ -90,7 +90,14 @@ class SyncCacheProxy:
90
90
  logger.debug(
91
91
  "Found matching cached response for the request",
92
92
  )
93
- pair.response.metadata["hishel_from_cache"] = True # type: ignore
93
+ response_meta = ResponseMetadata(
94
+ hishel_spec_ignored=True,
95
+ hishel_from_cache=True,
96
+ hishel_created_at=pair.meta.created_at,
97
+ hishel_revalidated=False,
98
+ hishel_stored=False,
99
+ )
100
+ pair.response.metadata.update(response_meta) # type: ignore
94
101
  self._maybe_refresh_pair_ttl(pair)
95
102
  return pair.response
96
103
 
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import calendar
4
+ import time
5
+ import typing as tp
6
+ from email.utils import parsedate_tz
7
+ from typing import AsyncIterator, Iterable, Iterator
8
+
9
+ HEADERS_ENCODING = "iso-8859-1"
10
+
11
+ T = tp.TypeVar("T")
12
+
13
+
14
+ def parse_date(date: str) -> tp.Optional[int]:
15
+ expires = parsedate_tz(date)
16
+ if expires is None:
17
+ return None
18
+ timestamp = calendar.timegm(expires[:6])
19
+ return timestamp
20
+
21
+
22
+ def sleep(seconds: tp.Union[int, float]) -> None:
23
+ time.sleep(seconds)
24
+
25
+
26
+ def partition(iterable: tp.Iterable[T], predicate: tp.Callable[[T], bool]) -> tp.Tuple[tp.List[T], tp.List[T]]:
27
+ """
28
+ Partition an iterable into two lists: one for matching items and one for non-matching items.
29
+
30
+ Args:
31
+ iterable (tp.Iterable[T]): The input iterable to partition.
32
+ predicate (tp.Callable[[T], bool]): A function that evaluates each item in the iterable.
33
+
34
+ Returns:
35
+ tp.Tuple[tp.List[T], tp.List[T]]: A tuple containing two lists: the first for matching items,
36
+ and the second for non-matching items.
37
+ Example:
38
+ ```
39
+ iterable = [1, 2, 3, 4, 5]
40
+ is_even = lambda x: x % 2 == 0
41
+ evens, odds = partition(iterable, is_even)
42
+ ```
43
+ """
44
+ matching, non_matching = [], []
45
+ for item in iterable:
46
+ if predicate(item):
47
+ matching.append(item)
48
+ else:
49
+ non_matching.append(item)
50
+ return matching, non_matching
51
+
52
+
53
+ async def make_async_iterator(iterable: Iterable[bytes]) -> AsyncIterator[bytes]:
54
+ for item in iterable:
55
+ yield item
56
+
57
+
58
+ def make_sync_iterator(iterable: Iterable[bytes]) -> Iterator[bytes]:
59
+ for item in iterable:
60
+ yield item
61
+
62
+
63
+ def snake_to_header(text: str) -> str:
64
+ """
65
+ Convert snake_case string to Header-Case format.
66
+
67
+ Args:
68
+ text: Snake case string (e.g., "hishel_ttl")
69
+
70
+ Returns:
71
+ Header case string (e.g., "X-Hishel-Ttl")
72
+
73
+ Examples:
74
+ >>> snake_to_header("hishel_ttl")
75
+ 'X-Hishel-Ttl'
76
+ >>> snake_to_header("cache_control")
77
+ 'X-Cache-Control'
78
+ >>> snake_to_header("content_type")
79
+ 'X-Content-Type'
80
+ """
81
+ # Split by underscore, capitalize each word, join with dash, add X- prefix
82
+ return "X-" + "-".join(word.capitalize() for word in text.split("_"))
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hishel"
7
- version = "1.0.0.dev1"
7
+ version = "1.0.0.dev2"
8
8
  dynamic = ["readme"]
9
9
  description = " Elegant HTTP Caching for Python"
10
10
  license = "BSD-3-Clause"
@@ -1,218 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import calendar
4
- import hashlib
5
- import time
6
- import typing as tp
7
- from email.utils import parsedate_tz
8
- from typing import AsyncIterator, Generator, Iterable, Iterator, TypeVar
9
-
10
- import httpcore
11
- import httpx
12
-
13
- HEADERS_ENCODING = "iso-8859-1"
14
-
15
- T = tp.TypeVar("T")
16
-
17
-
18
- class BaseClock:
19
- def now(self) -> int:
20
- raise NotImplementedError()
21
-
22
-
23
- class Clock(BaseClock):
24
- def now(self) -> int:
25
- return int(time.time())
26
-
27
-
28
- def normalized_url(url: tp.Union[httpcore.URL, str, bytes]) -> str:
29
- if isinstance(url, str): # pragma: no cover
30
- return url
31
-
32
- if isinstance(url, bytes): # pragma: no cover
33
- return url.decode("ascii")
34
-
35
- if isinstance(url, httpcore.URL):
36
- port = f":{url.port}" if url.port is not None else ""
37
- return f"{url.scheme.decode('ascii')}://{url.host.decode('ascii')}{port}{url.target.decode('ascii')}"
38
- assert False, "Invalid type for `normalized_url`" # pragma: no cover
39
-
40
-
41
- def get_safe_url(url: httpcore.URL) -> str:
42
- httpx_url = httpx.URL(bytes(url).decode("ascii"))
43
-
44
- schema = httpx_url.scheme
45
- host = httpx_url.host
46
- path = httpx_url.path
47
-
48
- return f"{schema}://{host}{path}"
49
-
50
-
51
- def generate_key(request: httpcore.Request, body: bytes = b"") -> str:
52
- encoded_url = normalized_url(request.url).encode("ascii")
53
-
54
- key_parts = [request.method, encoded_url, body]
55
-
56
- # FIPs mode disables blake2 algorithm, use sha256 instead when not found.
57
- blake2b_hasher = None
58
- sha256_hasher = hashlib.sha256(usedforsecurity=False)
59
- try:
60
- blake2b_hasher = hashlib.blake2b(digest_size=16, usedforsecurity=False)
61
- except (ValueError, TypeError, AttributeError):
62
- pass
63
-
64
- hexdigest: str
65
- if blake2b_hasher:
66
- for part in key_parts:
67
- blake2b_hasher.update(part)
68
-
69
- hexdigest = blake2b_hasher.hexdigest()
70
- else:
71
- for part in key_parts:
72
- sha256_hasher.update(part)
73
-
74
- hexdigest = sha256_hasher.hexdigest()
75
- return hexdigest
76
-
77
-
78
- def extract_header_values(
79
- headers: tp.List[tp.Tuple[bytes, bytes]],
80
- header_key: tp.Union[bytes, str],
81
- single: bool = False,
82
- ) -> tp.List[bytes]:
83
- if isinstance(header_key, str):
84
- header_key = header_key.encode(HEADERS_ENCODING)
85
- extracted_headers = []
86
- for key, value in headers:
87
- if key.lower() == header_key.lower():
88
- extracted_headers.append(value)
89
- if single:
90
- break
91
- return extracted_headers
92
-
93
-
94
- def extract_header_values_decoded(
95
- headers: tp.List[tp.Tuple[bytes, bytes]], header_key: bytes, single: bool = False
96
- ) -> tp.List[str]:
97
- values = extract_header_values(headers=headers, header_key=header_key, single=single)
98
- return [value.decode(HEADERS_ENCODING) for value in values]
99
-
100
-
101
- def header_presents(headers: tp.List[tp.Tuple[bytes, bytes]], header_key: bytes) -> bool:
102
- return bool(extract_header_values(headers, header_key, single=True))
103
-
104
-
105
- def parse_date(date: str) -> tp.Optional[int]:
106
- expires = parsedate_tz(date)
107
- if expires is None:
108
- return None
109
- timestamp = calendar.timegm(expires[:6])
110
- return timestamp
111
-
112
-
113
- def sleep(seconds: tp.Union[int, float]) -> None:
114
- time.sleep(seconds)
115
-
116
-
117
- def float_seconds_to_int_milliseconds(seconds: float) -> int:
118
- return int(seconds * 1000)
119
-
120
-
121
- def partition(iterable: tp.Iterable[T], predicate: tp.Callable[[T], bool]) -> tp.Tuple[tp.List[T], tp.List[T]]:
122
- """
123
- Partition an iterable into two lists: one for matching items and one for non-matching items.
124
-
125
- Args:
126
- iterable (tp.Iterable[T]): The input iterable to partition.
127
- predicate (tp.Callable[[T], bool]): A function that evaluates each item in the iterable.
128
-
129
- Returns:
130
- tp.Tuple[tp.List[T], tp.List[T]]: A tuple containing two lists: the first for matching items,
131
- and the second for non-matching items.
132
- Example:
133
- ```
134
- iterable = [1, 2, 3, 4, 5]
135
- is_even = lambda x: x % 2 == 0
136
- evens, odds = partition(iterable, is_even)
137
- ```
138
- """
139
- matching, non_matching = [], []
140
- for item in iterable:
141
- if predicate(item):
142
- matching.append(item)
143
- else:
144
- non_matching.append(item)
145
- return matching, non_matching
146
-
147
-
148
- async def make_async_iterator(iterable: Iterable[bytes]) -> AsyncIterator[bytes]:
149
- for item in iterable:
150
- yield item
151
-
152
-
153
- def make_sync_iterator(iterable: Iterable[bytes]) -> Iterator[bytes]:
154
- for item in iterable:
155
- yield item
156
-
157
-
158
- def snake_to_header(text: str) -> str:
159
- """
160
- Convert snake_case string to Header-Case format.
161
-
162
- Args:
163
- text: Snake case string (e.g., "hishel_ttl")
164
-
165
- Returns:
166
- Header case string (e.g., "X-Hishel-Ttl")
167
-
168
- Examples:
169
- >>> snake_to_header("hishel_ttl")
170
- 'X-Hishel-Ttl'
171
- >>> snake_to_header("cache_control")
172
- 'X-Cache-Control'
173
- >>> snake_to_header("content_type")
174
- 'X-Content-Type'
175
- """
176
- # Split by underscore, capitalize each word, join with dash, add X- prefix
177
- return "X-" + "-".join(word.capitalize() for word in text.split("_"))
178
-
179
-
180
- _T = TypeVar("_T")
181
-
182
-
183
- class GeneratorWithReturnValue:
184
- def __init__(
185
- self, gen: Generator[None, bytes | None, bytes], stream: AsyncIterator[bytes] | Iterator[bytes]
186
- ) -> None:
187
- self.gen = gen
188
- self.stream = stream
189
- self.value: bytes | None = None
190
-
191
- def __iter__(self) -> Iterator[bytes]:
192
- return self
193
-
194
- def __next__(self) -> bytes:
195
- assert isinstance(self.stream, Iterator)
196
-
197
- try:
198
- chunk = next(self.stream)
199
- self.gen.send(chunk)
200
- except StopIteration as exc:
201
- self.gen.send(None)
202
- self.value = exc.value
203
- raise
204
- return chunk
205
-
206
- def __aiter__(self) -> AsyncIterator[bytes]:
207
- return self
208
-
209
- async def __anext__(self) -> bytes:
210
- assert isinstance(self.stream, AsyncIterator)
211
- try:
212
- chunk = await self.stream.__anext__()
213
- self.gen.send(chunk)
214
- except StopIteration as exc:
215
- self.gen.send(None)
216
- self.value = exc.value
217
- raise
218
- return chunk
File without changes
File without changes
File without changes
File without changes
File without changes