hishel 1.0.0.dev1__py3-none-any.whl → 1.0.0.dev3__py3-none-any.whl

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.
hishel/_sync_cache.py CHANGED
@@ -4,7 +4,7 @@ import hashlib
4
4
  import logging
5
5
  import time
6
6
  from dataclasses import replace
7
- from typing import Iterator, Awaitable, Callable
7
+ from typing import Iterable, Iterator, Awaitable, Callable
8
8
 
9
9
  from typing_extensions import assert_never
10
10
 
@@ -24,8 +24,9 @@ from hishel import (
24
24
  StoreAndUse,
25
25
  create_idle_state,
26
26
  )
27
- from hishel._core._spec import InvalidatePairs, vary_headers_match
28
- from hishel._core.models import CompletePair
27
+ from hishel._core._spec import InvalidateEntries, vary_headers_match
28
+ from hishel._core.models import Entry, ResponseMetadata
29
+ from hishel._utils import make_sync_iterator
29
30
 
30
31
  logger = logging.getLogger("hishel.integrations.clients")
31
32
 
@@ -42,12 +43,12 @@ class SyncCacheProxy:
42
43
 
43
44
  def __init__(
44
45
  self,
45
- send_request: Callable[[Request], Response],
46
+ request_sender: Callable[[Request], Response],
46
47
  storage: SyncBaseStorage | None = None,
47
48
  cache_options: CacheOptions | None = None,
48
49
  ignore_specification: bool = False,
49
50
  ) -> None:
50
- self.send_request = send_request
51
+ self.send_request = request_sender
51
52
  self.storage = storage if storage is not None else SyncSqliteStorage()
52
53
  self.cache_options = cache_options if cache_options is not None else CacheOptions()
53
54
  self.ignore_specification = ignore_specification
@@ -59,26 +60,30 @@ class SyncCacheProxy:
59
60
 
60
61
  def _get_key_for_request(self, request: Request) -> str:
61
62
  if request.metadata.get("hishel_body_key"):
62
- assert isinstance(request.stream, Iterator)
63
+ assert isinstance(request.stream, (Iterator, Iterable))
63
64
  collected = b"".join([chunk for chunk in request.stream])
64
65
  hash_ = hashlib.sha256(collected).hexdigest()
66
+ request.stream = make_sync_iterator([collected])
65
67
  return f"{str(request.url)}-{hash_}"
66
- return str(request.url)
68
+ return hashlib.sha256(str(request.url).encode("utf-8")).hexdigest()
67
69
 
68
- def _maybe_refresh_pair_ttl(self, pair: CompletePair) -> None:
70
+ def _maybe_refresh_pair_ttl(self, pair: Entry) -> None:
69
71
  if pair.request.metadata.get("hishel_refresh_ttl_on_access"):
70
- self.storage.update_pair(
72
+ self.storage.update_entry(
71
73
  pair.id,
72
- lambda complete_pair: replace(complete_pair, meta=replace(complete_pair.meta, created_at=time.time())),
74
+ lambda complete_pair: replace(
75
+ complete_pair,
76
+ meta=replace(complete_pair.meta, created_at=time.time()),
77
+ ),
73
78
  )
74
79
 
75
80
  def _handle_request_ignoring_spec(self, request: Request) -> Response:
76
81
  logger.debug("Trying to get cached response ignoring specification")
77
- pairs = self.storage.get_pairs(self._get_key_for_request(request))
82
+ entries = self.storage.get_entries(self._get_key_for_request(request))
78
83
 
79
- logger.debug(f"Found {len(pairs)} cached pairs for the request")
84
+ logger.debug(f"Found {len(entries)} cached entries for the request")
80
85
 
81
- for pair in pairs:
86
+ for pair in entries:
82
87
  if (
83
88
  str(pair.request.url) == str(request.url)
84
89
  and pair.request.method == request.method
@@ -90,20 +95,26 @@ class SyncCacheProxy:
90
95
  logger.debug(
91
96
  "Found matching cached response for the request",
92
97
  )
93
- pair.response.metadata["hishel_from_cache"] = True # type: ignore
98
+ response_meta = ResponseMetadata(
99
+ hishel_spec_ignored=True,
100
+ hishel_from_cache=True,
101
+ hishel_created_at=pair.meta.created_at,
102
+ hishel_revalidated=False,
103
+ hishel_stored=False,
104
+ )
105
+ pair.response.metadata.update(response_meta) # type: ignore
94
106
  self._maybe_refresh_pair_ttl(pair)
95
107
  return pair.response
96
108
 
97
- incomplete_pair = self.storage.create_pair(
98
- request,
99
- )
100
- response = self.send_request(incomplete_pair.request)
109
+ response = self.send_request(request)
101
110
 
102
111
  logger.debug("Storing response in cache ignoring specification")
103
- complete_pair = self.storage.add_response(
104
- incomplete_pair.id, response, self._get_key_for_request(request)
112
+ entry = self.storage.create_entry(
113
+ request,
114
+ response,
115
+ self._get_key_for_request(request),
105
116
  )
106
- return complete_pair.response
117
+ return entry.response
107
118
 
108
119
  def _handle_request_respecting_spec(self, request: Request) -> Response:
109
120
  state: AnyState = create_idle_state("client", self.cache_options)
@@ -125,25 +136,26 @@ class SyncCacheProxy:
125
136
  return state.pair.response
126
137
  elif isinstance(state, NeedToBeUpdated):
127
138
  state = self._handle_update(state)
128
- elif isinstance(state, InvalidatePairs):
129
- state = self._handle_invalidate_pairs(state)
139
+ elif isinstance(state, InvalidateEntries):
140
+ state = self._handle_invalidate_entries(state)
130
141
  else:
131
142
  assert_never(state)
132
143
 
133
144
  raise RuntimeError("Unreachable")
134
145
 
135
146
  def _handle_idle_state(self, state: IdleClient, request: Request) -> AnyState:
136
- stored_pairs = self.storage.get_pairs(self._get_key_for_request(request))
137
- return state.next(request, stored_pairs)
147
+ stored_entries = self.storage.get_entries(self._get_key_for_request(request))
148
+ return state.next(request, stored_entries)
138
149
 
139
150
  def _handle_cache_miss(self, state: CacheMiss) -> AnyState:
140
- incomplete_pair = self.storage.create_pair(state.request)
141
- response = self.send_request(incomplete_pair.request)
142
- return state.next(response, incomplete_pair.id)
151
+ response = self.send_request(state.request)
152
+ return state.next(response)
143
153
 
144
154
  def _handle_store_and_use(self, state: StoreAndUse, request: Request) -> Response:
145
- complete_pair = self.storage.add_response(
146
- state.pair_id, state.response, self._get_key_for_request(request)
155
+ complete_pair = self.storage.create_entry(
156
+ request,
157
+ state.response,
158
+ self._get_key_for_request(request),
147
159
  )
148
160
  return complete_pair.response
149
161
 
@@ -152,16 +164,17 @@ class SyncCacheProxy:
152
164
  return state.next(revalidation_response)
153
165
 
154
166
  def _handle_update(self, state: NeedToBeUpdated) -> AnyState:
155
- for pair in state.updating_pairs:
156
- self.storage.update_pair(
157
- pair.id,
167
+ for entry in state.updating_entries:
168
+ self.storage.update_entry(
169
+ entry.id,
158
170
  lambda complete_pair: replace(
159
- complete_pair, response=replace(pair.response, headers=pair.response.headers)
171
+ complete_pair,
172
+ response=replace(entry.response, headers=entry.response.headers),
160
173
  ),
161
174
  )
162
175
  return state.next()
163
176
 
164
- def _handle_invalidate_pairs(self, state: InvalidatePairs) -> AnyState:
165
- for pair_id in state.pair_ids:
166
- self.storage.remove(pair_id)
177
+ def _handle_invalidate_entries(self, state: InvalidateEntries) -> AnyState:
178
+ for entry_id in state.entry_ids:
179
+ self.storage.remove_entry(entry_id)
167
180
  return state.next()
hishel/_sync_httpx.py ADDED
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import ssl
4
+ import typing as t
5
+ from typing import (
6
+ Iterable,
7
+ Iterator,
8
+ Iterator,
9
+ Union,
10
+ cast,
11
+ overload,
12
+ )
13
+
14
+ from httpx import RequestNotRead
15
+
16
+ from hishel import SyncCacheProxy, Headers, Request, Response
17
+ from hishel._core._spec import (
18
+ CacheOptions,
19
+ )
20
+ from hishel._core._storages._sync_base import SyncBaseStorage
21
+ from hishel._core.models import RequestMetadata, extract_metadata_from_headers
22
+ from hishel._utils import (
23
+ filter_mapping,
24
+ make_sync_iterator,
25
+ )
26
+
27
+ try:
28
+ import httpx
29
+ except ImportError as e:
30
+ raise ImportError(
31
+ "httpx is required to use hishel.httpx module. "
32
+ "Please install hishel with the 'httpx' extra, "
33
+ "e.g., 'pip install hishel[httpx]'."
34
+ ) from e
35
+
36
+ SOCKET_OPTION = t.Union[
37
+ t.Tuple[int, int, int],
38
+ t.Tuple[int, int, t.Union[bytes, bytearray]],
39
+ t.Tuple[int, int, None, int],
40
+ ]
41
+
42
+ # 128 KB
43
+ CHUNK_SIZE = 131072
44
+
45
+
46
+ @overload
47
+ def _internal_to_httpx(
48
+ value: Request,
49
+ ) -> httpx.Request: ...
50
+ @overload
51
+ def _internal_to_httpx(
52
+ value: Response,
53
+ ) -> httpx.Response: ...
54
+ def _internal_to_httpx(
55
+ value: Union[Request, Response],
56
+ ) -> Union[httpx.Request, httpx.Response]:
57
+ """
58
+ Convert internal Request/Response to httpx.Request/httpx.Response.
59
+ """
60
+ if isinstance(value, Request):
61
+ return httpx.Request(
62
+ method=value.method,
63
+ url=value.url,
64
+ headers=value.headers,
65
+ stream=_IteratorStream(value.iter_stream()),
66
+ extensions=value.metadata,
67
+ )
68
+ elif isinstance(value, Response):
69
+ return httpx.Response(
70
+ status_code=value.status_code,
71
+ headers=value.headers,
72
+ stream=_IteratorStream(value.iter_stream()),
73
+ extensions=value.metadata,
74
+ )
75
+
76
+
77
+ @overload
78
+ def _httpx_to_internal(
79
+ value: httpx.Request,
80
+ ) -> Request: ...
81
+ @overload
82
+ def _httpx_to_internal(
83
+ value: httpx.Response,
84
+ ) -> Response: ...
85
+ def _httpx_to_internal(
86
+ value: Union[httpx.Request, httpx.Response],
87
+ ) -> Union[Request, Response]:
88
+ """
89
+ Convert httpx.Request/httpx.Response to internal Request/Response.
90
+ """
91
+ headers = Headers(
92
+ filter_mapping(
93
+ Headers({key: value for key, value in value.headers.items()}),
94
+ ["Transfer-Encoding"],
95
+ )
96
+ )
97
+ if isinstance(value, httpx.Request):
98
+ extension_metadata = RequestMetadata(
99
+ hishel_refresh_ttl_on_access=value.extensions.get("hishel_refresh_ttl_on_access"),
100
+ hishel_ttl=value.extensions.get("hishel_ttl"),
101
+ hishel_spec_ignore=value.extensions.get("hishel_spec_ignore"),
102
+ hishel_body_key=value.extensions.get("hishel_body_key"),
103
+ )
104
+ headers_metadata = extract_metadata_from_headers(value.headers)
105
+
106
+ for key, val in extension_metadata.items():
107
+ if key in value.extensions:
108
+ headers_metadata[key] = val # type: ignore
109
+
110
+ try:
111
+ stream = make_sync_iterator([value.content])
112
+ except RequestNotRead:
113
+ stream = cast(Iterator[bytes], value.stream)
114
+
115
+ return Request(
116
+ method=value.method,
117
+ url=str(value.url),
118
+ headers=headers,
119
+ stream=stream,
120
+ metadata=headers_metadata,
121
+ )
122
+ elif isinstance(value, httpx.Response):
123
+ if value.is_stream_consumed and "content-encoding" in value.headers:
124
+ raise RuntimeError("Can't get the raw stream of a response with `Content-Encoding` header.")
125
+ stream = (
126
+ make_sync_iterator([value.content]) if value.is_stream_consumed else value.iter_raw(chunk_size=CHUNK_SIZE)
127
+ )
128
+
129
+ return Response(
130
+ status_code=value.status_code,
131
+ headers=headers,
132
+ stream=stream,
133
+ metadata={},
134
+ )
135
+
136
+
137
+ class _IteratorStream(httpx.SyncByteStream, httpx.AsyncByteStream):
138
+ def __init__(self, iterator: Iterator[bytes] | Iterator[bytes]) -> None:
139
+ self.iterator = iterator
140
+
141
+ def __iter__(self) -> Iterator[bytes]:
142
+ assert isinstance(self.iterator, (Iterator, Iterable))
143
+ for chunk in self.iterator:
144
+ yield chunk
145
+
146
+
147
+ class SyncCacheTransport(httpx.BaseTransport):
148
+ def __init__(
149
+ self,
150
+ next_transport: httpx.BaseTransport,
151
+ storage: SyncBaseStorage | None = None,
152
+ cache_options: CacheOptions | None = None,
153
+ ignore_specification: bool = False,
154
+ ) -> None:
155
+ self.next_transport = next_transport
156
+ self._cache_proxy: SyncCacheProxy = SyncCacheProxy(
157
+ request_sender=self.request_sender,
158
+ storage=storage,
159
+ cache_options=cache_options,
160
+ ignore_specification=ignore_specification,
161
+ )
162
+ self.storage = self._cache_proxy.storage
163
+
164
+ def handle_request(
165
+ self,
166
+ request: httpx.Request,
167
+ ) -> httpx.Response:
168
+ internal_request = _httpx_to_internal(request)
169
+ internal_response = self._cache_proxy.handle_request(internal_request)
170
+ response = _internal_to_httpx(internal_response)
171
+ return response
172
+
173
+ def close(self) -> None:
174
+ self.next_transport.close()
175
+ self.storage.close()
176
+ super().close()
177
+
178
+ def request_sender(self, request: Request) -> Response:
179
+ httpx_request = _internal_to_httpx(request)
180
+ httpx_response = self.next_transport.handle_request(httpx_request)
181
+ return _httpx_to_internal(httpx_response)
182
+
183
+
184
+ class SyncCacheClient(httpx.Client):
185
+ def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
186
+ self.storage: SyncBaseStorage | None = kwargs.pop("storage", None)
187
+ self.cache_options: CacheOptions | None = kwargs.pop("cache_options", None)
188
+ self.ignore_specification: bool = kwargs.pop("ignore_specification", False)
189
+ super().__init__(*args, **kwargs)
190
+
191
+ def _init_transport(
192
+ self,
193
+ verify: ssl.SSLContext | str | bool = True,
194
+ cert: t.Union[str, t.Tuple[str, str], t.Tuple[str, str, str], None] = None,
195
+ trust_env: bool = True,
196
+ http1: bool = True,
197
+ http2: bool = False,
198
+ limits: httpx.Limits = httpx.Limits(max_connections=100, max_keepalive_connections=20),
199
+ transport: httpx.BaseTransport | None = None,
200
+ **kwargs: t.Any,
201
+ ) -> httpx.BaseTransport:
202
+ if transport is not None:
203
+ return transport
204
+
205
+ return SyncCacheTransport(
206
+ next_transport=httpx.HTTPTransport(
207
+ verify=verify,
208
+ cert=cert,
209
+ trust_env=trust_env,
210
+ http1=http1,
211
+ http2=http2,
212
+ limits=limits,
213
+ ),
214
+ storage=self.storage,
215
+ cache_options=self.cache_options,
216
+ ignore_specification=False,
217
+ )
218
+
219
+ def _init_proxy_transport(
220
+ self,
221
+ proxy: httpx.Proxy,
222
+ verify: ssl.SSLContext | str | bool = True,
223
+ cert: t.Union[str, t.Tuple[str, str], t.Tuple[str, str, str], None] = None,
224
+ trust_env: bool = True,
225
+ http1: bool = True,
226
+ http2: bool = False,
227
+ limits: httpx.Limits = httpx.Limits(max_connections=100, max_keepalive_connections=20),
228
+ **kwargs: t.Any,
229
+ ) -> httpx.BaseTransport:
230
+ return SyncCacheTransport(
231
+ next_transport=httpx.HTTPTransport(
232
+ verify=verify,
233
+ cert=cert,
234
+ trust_env=trust_env,
235
+ http1=http1,
236
+ http2=http2,
237
+ limits=limits,
238
+ proxy=proxy,
239
+ ),
240
+ storage=self.storage,
241
+ cache_options=self.cache_options,
242
+ ignore_specification=self.ignore_specification,
243
+ )
hishel/_utils.py CHANGED
@@ -1,107 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import calendar
4
- import hashlib
5
4
  import time
6
5
  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
6
+ from email.utils import formatdate, parsedate_tz
7
+ from pathlib import Path
8
+ from typing import AsyncIterator, Iterable, Iterator
12
9
 
13
10
  HEADERS_ENCODING = "iso-8859-1"
14
11
 
15
12
  T = tp.TypeVar("T")
16
13
 
17
14
 
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
15
  def parse_date(date: str) -> tp.Optional[int]:
106
16
  expires = parsedate_tz(date)
107
17
  if expires is None:
@@ -114,10 +24,6 @@ def sleep(seconds: tp.Union[int, float]) -> None:
114
24
  time.sleep(seconds)
115
25
 
116
26
 
117
- def float_seconds_to_int_milliseconds(seconds: float) -> int:
118
- return int(seconds * 1000)
119
-
120
-
121
27
  def partition(iterable: tp.Iterable[T], predicate: tp.Callable[[T], bool]) -> tp.Tuple[tp.List[T], tp.List[T]]:
122
28
  """
123
29
  Partition an iterable into two lists: one for matching items and one for non-matching items.
@@ -145,11 +51,35 @@ def partition(iterable: tp.Iterable[T], predicate: tp.Callable[[T], bool]) -> tp
145
51
  return matching, non_matching
146
52
 
147
53
 
148
- async def make_async_iterator(iterable: Iterable[bytes]) -> AsyncIterator[bytes]:
54
+ async def make_async_iterator(
55
+ iterable: Iterable[bytes],
56
+ ) -> AsyncIterator[bytes]:
149
57
  for item in iterable:
150
58
  yield item
151
59
 
152
60
 
61
+ def filter_mapping(mapping: tp.Mapping[str, T], keys_to_exclude: tp.Iterable[str]) -> tp.Dict[str, T]:
62
+ """
63
+ Filter out specified keys from a string-keyed mapping using case-insensitive comparison.
64
+
65
+ Args:
66
+ mapping: The input mapping with string keys to filter.
67
+ keys_to_exclude: An iterable of string keys to exclude (case-insensitive).
68
+
69
+ Returns:
70
+ A new dictionary with the specified keys excluded.
71
+
72
+ Example:
73
+ ```python
74
+ original = {'a': 1, 'B': 2, 'c': 3}
75
+ filtered = filter_mapping(original, ['b'])
76
+ # filtered will be {'a': 1, 'c': 3}
77
+ ```
78
+ """
79
+ exclude_set = {k.lower() for k in keys_to_exclude}
80
+ return {k: v for k, v in mapping.items() if k.lower() not in exclude_set}
81
+
82
+
153
83
  def make_sync_iterator(iterable: Iterable[bytes]) -> Iterator[bytes]:
154
84
  for item in iterable:
155
85
  yield item
@@ -177,42 +107,23 @@ def snake_to_header(text: str) -> str:
177
107
  return "X-" + "-".join(word.capitalize() for word in text.split("_"))
178
108
 
179
109
 
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
110
+ def ensure_cache_dict(base_path: str | None = None) -> Path:
111
+ _base_path = Path(base_path) if base_path is not None else Path(".cache/hishel")
112
+ _gitignore_file = _base_path / ".gitignore"
113
+
114
+ _base_path.mkdir(parents=True, exist_ok=True)
115
+
116
+ if not _gitignore_file.is_file():
117
+ with open(_gitignore_file, "w", encoding="utf-8") as f:
118
+ f.write("# Automatically created by Hishel\n*")
119
+ return _base_path
120
+
121
+
122
+ def generate_http_date() -> str:
123
+ """
124
+ Generate a Date header value for HTTP responses.
125
+ Returns date in RFC 1123 format (required by HTTP/1.1).
126
+
127
+ Example output: 'Sun, 26 Oct 2025 12:34:56 GMT'
128
+ """
129
+ return formatdate(timeval=None, localtime=False, usegmt=True)