hishel 1.0.0.dev2__py3-none-any.whl → 1.1.0__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/_policies.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import typing as t
5
+ from dataclasses import dataclass, field
6
+ from typing import Generic
7
+
8
+ from hishel import Request, Response
9
+ from hishel._core._spec import (
10
+ CacheOptions,
11
+ )
12
+
13
+ logger = __import__("logging").getLogger(__name__)
14
+
15
+ T = t.TypeVar("T", Request, Response)
16
+
17
+
18
+ class CachePolicy(abc.ABC):
19
+ use_body_key: bool = False
20
+ """Whether to include request body in cache key calculation."""
21
+
22
+
23
+ class BaseFilter(abc.ABC, Generic[T]):
24
+ @abc.abstractmethod
25
+ def needs_body(self) -> bool:
26
+ pass
27
+
28
+ @abc.abstractmethod
29
+ def apply(self, item: T, body: bytes | None) -> bool:
30
+ pass
31
+
32
+
33
+ @dataclass
34
+ class SpecificationPolicy(CachePolicy):
35
+ """
36
+ Caching policy that respects HTTP caching specification.
37
+ """
38
+
39
+ cache_options: CacheOptions = field(default_factory=CacheOptions)
40
+
41
+
42
+ @dataclass
43
+ class FilterPolicy(CachePolicy):
44
+ """
45
+ Caching policy that applies user-defined filtering logic.
46
+ """
47
+
48
+ request_filters: list[BaseFilter[Request]] = field(default_factory=list)
49
+ response_filters: list[BaseFilter[Response]] = field(default_factory=list)
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
 
@@ -13,7 +13,6 @@ from hishel import (
13
13
  SyncBaseStorage,
14
14
  SyncSqliteStorage,
15
15
  CacheMiss,
16
- CacheOptions,
17
16
  CouldNotBeStored,
18
17
  FromCache,
19
18
  IdleClient,
@@ -22,10 +21,11 @@ from hishel import (
22
21
  Request,
23
22
  Response,
24
23
  StoreAndUse,
25
- create_idle_state,
26
24
  )
27
- from hishel._core._spec import InvalidatePairs, vary_headers_match
28
- from hishel._core.models import CompletePair, ResponseMetadata
25
+ from hishel._core._spec import InvalidateEntries, vary_headers_match
26
+ from hishel._core.models import Entry, ResponseMetadata
27
+ from hishel._policies import CachePolicy, FilterPolicy, SpecificationPolicy
28
+ from hishel._utils import make_sync_iterator
29
29
 
30
30
  logger = logging.getLogger("hishel.integrations.clients")
31
31
 
@@ -36,84 +36,121 @@ class SyncCacheProxy:
36
36
 
37
37
  This class is independent of any specific HTTP library and works only with internal models.
38
38
  It delegates request execution to a user-provided callable, making it compatible with any
39
- HTTP client. Caching behavior can be configured to either fully respect HTTP
40
- caching rules or bypass them entirely.
39
+ HTTP client. Caching behavior is determined by the policy object.
40
+
41
+ Args:
42
+ request_sender: Callable that sends HTTP requests and returns responses.
43
+ storage: Storage backend for cache entries. Defaults to SyncSqliteStorage.
44
+ policy: Caching policy to use. Can be SpecificationPolicy (respects RFC 9111) or
45
+ FilterPolicy (user-defined filtering). Defaults to SpecificationPolicy().
41
46
  """
42
47
 
43
48
  def __init__(
44
49
  self,
45
- send_request: Callable[[Request], Response],
50
+ request_sender: Callable[[Request], Response],
46
51
  storage: SyncBaseStorage | None = None,
47
- cache_options: CacheOptions | None = None,
48
- ignore_specification: bool = False,
52
+ policy: CachePolicy | None = None,
49
53
  ) -> None:
50
- self.send_request = send_request
54
+ self.send_request = request_sender
51
55
  self.storage = storage if storage is not None else SyncSqliteStorage()
52
- self.cache_options = cache_options if cache_options is not None else CacheOptions()
53
- self.ignore_specification = ignore_specification
56
+ self.policy = policy if policy is not None else SpecificationPolicy()
54
57
 
55
58
  def handle_request(self, request: Request) -> Response:
56
- if self.ignore_specification or request.metadata.get("hishel_spec_ignore"):
57
- return self._handle_request_ignoring_spec(request)
59
+ if isinstance(self.policy, FilterPolicy):
60
+ return self._handle_request_with_filters(request)
58
61
  return self._handle_request_respecting_spec(request)
59
62
 
60
63
  def _get_key_for_request(self, request: Request) -> str:
61
- if request.metadata.get("hishel_body_key"):
62
- assert isinstance(request.stream, Iterator)
64
+ if self.policy.use_body_key or request.metadata.get("hishel_body_key"):
65
+ assert isinstance(request.stream, (Iterator, Iterable))
63
66
  collected = b"".join([chunk for chunk in request.stream])
64
67
  hash_ = hashlib.sha256(collected).hexdigest()
65
- return f"{str(request.url)}-{hash_}"
66
- return str(request.url)
67
-
68
- def _maybe_refresh_pair_ttl(self, pair: CompletePair) -> None:
69
- if pair.request.metadata.get("hishel_refresh_ttl_on_access"):
70
- self.storage.update_pair(
71
- pair.id,
72
- lambda complete_pair: replace(complete_pair, meta=replace(complete_pair.meta, created_at=time.time())),
68
+ request.stream = make_sync_iterator([collected])
69
+ return hash_
70
+ return hashlib.sha256(str(request.url).encode("utf-8")).hexdigest()
71
+
72
+ def _maybe_refresh_entry_ttl(self, entry: Entry) -> None:
73
+ if entry.request.metadata.get("hishel_refresh_ttl_on_access"):
74
+ self.storage.update_entry(
75
+ entry.id,
76
+ lambda current_entry: replace(
77
+ current_entry,
78
+ meta=replace(current_entry.meta, created_at=time.time()),
79
+ ),
73
80
  )
74
81
 
75
- def _handle_request_ignoring_spec(self, request: Request) -> Response:
82
+ def _handle_request_with_filters(self, request: Request) -> Response:
83
+ assert isinstance(self.policy, FilterPolicy)
84
+
85
+ for request_filter in self.policy.request_filters:
86
+ if request_filter.needs_body():
87
+ body = request.read()
88
+ if not request_filter.apply(request, body):
89
+ logger.debug("Request filtered out by request filter")
90
+ return self.send_request(request)
91
+ else:
92
+ if not request_filter.apply(request, None):
93
+ logger.debug("Request filtered out by request filter")
94
+ return self.send_request(request)
95
+
76
96
  logger.debug("Trying to get cached response ignoring specification")
77
- pairs = self.storage.get_pairs(self._get_key_for_request(request))
97
+ cache_key = self._get_key_for_request(request)
98
+ entries = self.storage.get_entries(cache_key)
78
99
 
79
- logger.debug(f"Found {len(pairs)} cached pairs for the request")
100
+ logger.debug(f"Found {len(entries)} cached entries for the request")
80
101
 
81
- for pair in pairs:
102
+ for entry in entries:
82
103
  if (
83
- str(pair.request.url) == str(request.url)
84
- and pair.request.method == request.method
104
+ str(entry.request.url) == str(request.url)
105
+ and entry.request.method == request.method
85
106
  and vary_headers_match(
86
107
  request,
87
- pair,
108
+ entry,
88
109
  )
89
110
  ):
90
111
  logger.debug(
91
112
  "Found matching cached response for the request",
92
113
  )
93
114
  response_meta = ResponseMetadata(
94
- hishel_spec_ignored=True,
95
115
  hishel_from_cache=True,
96
- hishel_created_at=pair.meta.created_at,
116
+ hishel_created_at=entry.meta.created_at,
97
117
  hishel_revalidated=False,
98
118
  hishel_stored=False,
99
119
  )
100
- pair.response.metadata.update(response_meta) # type: ignore
101
- self._maybe_refresh_pair_ttl(pair)
102
- return pair.response
103
-
104
- incomplete_pair = self.storage.create_pair(
105
- request,
120
+ entry.response.metadata.update(response_meta) # type: ignore
121
+ self._maybe_refresh_entry_ttl(entry)
122
+ return entry.response
123
+
124
+ response = self.send_request(request)
125
+ for response_filter in self.policy.response_filters:
126
+ if response_filter.needs_body():
127
+ body = response.read()
128
+ if not response_filter.apply(response, body):
129
+ logger.debug("Response filtered out by response filter")
130
+ return response
131
+ else:
132
+ if not response_filter.apply(response, None):
133
+ logger.debug("Response filtered out by response filter")
134
+ return response
135
+ response_meta = ResponseMetadata(
136
+ hishel_from_cache=False,
137
+ hishel_created_at=time.time(),
138
+ hishel_revalidated=False,
139
+ hishel_stored=True,
106
140
  )
107
- response = self.send_request(incomplete_pair.request)
141
+ response.metadata.update(response_meta) # type: ignore
108
142
 
109
143
  logger.debug("Storing response in cache ignoring specification")
110
- complete_pair = self.storage.add_response(
111
- incomplete_pair.id, response, self._get_key_for_request(request)
144
+ entry = self.storage.create_entry(
145
+ request,
146
+ response,
147
+ cache_key,
112
148
  )
113
- return complete_pair.response
149
+ return entry.response
114
150
 
115
151
  def _handle_request_respecting_spec(self, request: Request) -> Response:
116
- state: AnyState = create_idle_state("client", self.cache_options)
152
+ assert isinstance(self.policy, SpecificationPolicy)
153
+ state: AnyState = IdleClient(options=self.policy.cache_options)
117
154
 
118
155
  while state:
119
156
  logger.debug(f"Handling state: {state.__class__.__name__}")
@@ -128,47 +165,49 @@ class SyncCacheProxy:
128
165
  elif isinstance(state, NeedRevalidation):
129
166
  state = self._handle_revalidation(state)
130
167
  elif isinstance(state, FromCache):
131
- self._maybe_refresh_pair_ttl(state.pair)
132
- return state.pair.response
168
+ self._maybe_refresh_entry_ttl(state.entry)
169
+ return state.entry.response
133
170
  elif isinstance(state, NeedToBeUpdated):
134
171
  state = self._handle_update(state)
135
- elif isinstance(state, InvalidatePairs):
136
- state = self._handle_invalidate_pairs(state)
172
+ elif isinstance(state, InvalidateEntries):
173
+ state = self._handle_invalidate_entries(state)
137
174
  else:
138
175
  assert_never(state)
139
176
 
140
177
  raise RuntimeError("Unreachable")
141
178
 
142
179
  def _handle_idle_state(self, state: IdleClient, request: Request) -> AnyState:
143
- stored_pairs = self.storage.get_pairs(self._get_key_for_request(request))
144
- return state.next(request, stored_pairs)
180
+ stored_entries = self.storage.get_entries(self._get_key_for_request(request))
181
+ return state.next(request, stored_entries)
145
182
 
146
183
  def _handle_cache_miss(self, state: CacheMiss) -> AnyState:
147
- incomplete_pair = self.storage.create_pair(state.request)
148
- response = self.send_request(incomplete_pair.request)
149
- return state.next(response, incomplete_pair.id)
184
+ response = self.send_request(state.request)
185
+ return state.next(response)
150
186
 
151
187
  def _handle_store_and_use(self, state: StoreAndUse, request: Request) -> Response:
152
- complete_pair = self.storage.add_response(
153
- state.pair_id, state.response, self._get_key_for_request(request)
188
+ entry = self.storage.create_entry(
189
+ request,
190
+ state.response,
191
+ self._get_key_for_request(request),
154
192
  )
155
- return complete_pair.response
193
+ return entry.response
156
194
 
157
195
  def _handle_revalidation(self, state: NeedRevalidation) -> AnyState:
158
196
  revalidation_response = self.send_request(state.request)
159
197
  return state.next(revalidation_response)
160
198
 
161
199
  def _handle_update(self, state: NeedToBeUpdated) -> AnyState:
162
- for pair in state.updating_pairs:
163
- self.storage.update_pair(
164
- pair.id,
165
- lambda complete_pair: replace(
166
- complete_pair, response=replace(pair.response, headers=pair.response.headers)
200
+ for entry in state.updating_entries:
201
+ self.storage.update_entry(
202
+ entry.id,
203
+ lambda entry: replace(
204
+ entry,
205
+ response=replace(entry.response, headers=entry.response.headers),
167
206
  ),
168
207
  )
169
208
  return state.next()
170
209
 
171
- def _handle_invalidate_pairs(self, state: InvalidatePairs) -> AnyState:
172
- for pair_id in state.pair_ids:
173
- self.storage.remove(pair_id)
210
+ def _handle_invalidate_entries(self, state: InvalidateEntries) -> AnyState:
211
+ for entry_id in state.entry_ids:
212
+ self.storage.remove_entry(entry_id)
174
213
  return state.next()
hishel/_sync_httpx.py ADDED
@@ -0,0 +1,236 @@
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._storages._sync_base import SyncBaseStorage
18
+ from hishel._core.models import RequestMetadata, extract_metadata_from_headers
19
+ from hishel._policies import CachePolicy
20
+ from hishel._utils import (
21
+ filter_mapping,
22
+ make_sync_iterator,
23
+ )
24
+
25
+ try:
26
+ import httpx
27
+ except ImportError as e:
28
+ raise ImportError(
29
+ "httpx is required to use hishel.httpx module. "
30
+ "Please install hishel with the 'httpx' extra, "
31
+ "e.g., 'pip install hishel[httpx]'."
32
+ ) from e
33
+
34
+ SOCKET_OPTION = t.Union[
35
+ t.Tuple[int, int, int],
36
+ t.Tuple[int, int, t.Union[bytes, bytearray]],
37
+ t.Tuple[int, int, None, int],
38
+ ]
39
+
40
+ # 128 KB
41
+ CHUNK_SIZE = 131072
42
+
43
+
44
+ @overload
45
+ def _internal_to_httpx(
46
+ value: Request,
47
+ ) -> httpx.Request: ...
48
+ @overload
49
+ def _internal_to_httpx(
50
+ value: Response,
51
+ ) -> httpx.Response: ...
52
+ def _internal_to_httpx(
53
+ value: Union[Request, Response],
54
+ ) -> Union[httpx.Request, httpx.Response]:
55
+ """
56
+ Convert internal Request/Response to httpx.Request/httpx.Response.
57
+ """
58
+ if isinstance(value, Request):
59
+ return httpx.Request(
60
+ method=value.method,
61
+ url=value.url,
62
+ headers=value.headers,
63
+ stream=_IteratorStream(value._iter_stream()),
64
+ extensions=value.metadata,
65
+ )
66
+ elif isinstance(value, Response):
67
+ return httpx.Response(
68
+ status_code=value.status_code,
69
+ headers=value.headers,
70
+ stream=_IteratorStream(value._iter_stream()),
71
+ extensions=value.metadata,
72
+ )
73
+
74
+
75
+ @overload
76
+ def _httpx_to_internal(
77
+ value: httpx.Request,
78
+ ) -> Request: ...
79
+ @overload
80
+ def _httpx_to_internal(
81
+ value: httpx.Response,
82
+ ) -> Response: ...
83
+ def _httpx_to_internal(
84
+ value: Union[httpx.Request, httpx.Response],
85
+ ) -> Union[Request, Response]:
86
+ """
87
+ Convert httpx.Request/httpx.Response to internal Request/Response.
88
+ """
89
+ headers = Headers(
90
+ filter_mapping(
91
+ Headers({key: value for key, value in value.headers.items()}),
92
+ ["Transfer-Encoding"],
93
+ )
94
+ )
95
+ if isinstance(value, httpx.Request):
96
+ extension_metadata = RequestMetadata(
97
+ hishel_refresh_ttl_on_access=value.extensions.get("hishel_refresh_ttl_on_access"),
98
+ hishel_ttl=value.extensions.get("hishel_ttl"),
99
+ hishel_spec_ignore=value.extensions.get("hishel_spec_ignore"),
100
+ hishel_body_key=value.extensions.get("hishel_body_key"),
101
+ )
102
+ headers_metadata = extract_metadata_from_headers(value.headers)
103
+
104
+ for key, val in extension_metadata.items():
105
+ if key in value.extensions:
106
+ headers_metadata[key] = val # type: ignore
107
+
108
+ try:
109
+ stream = make_sync_iterator([value.content])
110
+ except RequestNotRead:
111
+ stream = cast(Iterator[bytes], value.stream)
112
+
113
+ return Request(
114
+ method=value.method,
115
+ url=str(value.url),
116
+ headers=headers,
117
+ stream=stream,
118
+ metadata=headers_metadata,
119
+ )
120
+ elif isinstance(value, httpx.Response):
121
+ if value.is_stream_consumed and "content-encoding" in value.headers:
122
+ raise RuntimeError("Can't get the raw stream of a response with `Content-Encoding` header.")
123
+ stream = (
124
+ make_sync_iterator([value.content]) if value.is_stream_consumed else value.iter_raw(chunk_size=CHUNK_SIZE)
125
+ )
126
+
127
+ return Response(
128
+ status_code=value.status_code,
129
+ headers=headers,
130
+ stream=stream,
131
+ metadata={},
132
+ )
133
+
134
+
135
+ class _IteratorStream(httpx.SyncByteStream, httpx.AsyncByteStream):
136
+ def __init__(self, iterator: Iterator[bytes] | Iterator[bytes]) -> None:
137
+ self.iterator = iterator
138
+
139
+ def __iter__(self) -> Iterator[bytes]:
140
+ assert isinstance(self.iterator, (Iterator, Iterable))
141
+ for chunk in self.iterator:
142
+ yield chunk
143
+
144
+
145
+ class SyncCacheTransport(httpx.BaseTransport):
146
+ def __init__(
147
+ self,
148
+ next_transport: httpx.BaseTransport,
149
+ storage: SyncBaseStorage | None = None,
150
+ policy: CachePolicy | None = None,
151
+ ) -> None:
152
+ self.next_transport = next_transport
153
+ self._cache_proxy: SyncCacheProxy = SyncCacheProxy(
154
+ request_sender=self.request_sender,
155
+ storage=storage,
156
+ policy=policy,
157
+ )
158
+ self.storage = self._cache_proxy.storage
159
+
160
+ def handle_request(
161
+ self,
162
+ request: httpx.Request,
163
+ ) -> httpx.Response:
164
+ internal_request = _httpx_to_internal(request)
165
+ internal_response = self._cache_proxy.handle_request(internal_request)
166
+ response = _internal_to_httpx(internal_response)
167
+ return response
168
+
169
+ def close(self) -> None:
170
+ self.next_transport.close()
171
+ self.storage.close()
172
+ super().close()
173
+
174
+ def request_sender(self, request: Request) -> Response:
175
+ httpx_request = _internal_to_httpx(request)
176
+ httpx_response = self.next_transport.handle_request(httpx_request)
177
+ return _httpx_to_internal(httpx_response)
178
+
179
+
180
+ class SyncCacheClient(httpx.Client):
181
+ def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
182
+ self.storage: SyncBaseStorage | None = kwargs.pop("storage", None)
183
+ self.policy: CachePolicy | None = kwargs.pop("policy", None)
184
+ super().__init__(*args, **kwargs)
185
+
186
+ def _init_transport(
187
+ self,
188
+ verify: ssl.SSLContext | str | bool = True,
189
+ cert: t.Union[str, t.Tuple[str, str], t.Tuple[str, str, str], None] = None,
190
+ trust_env: bool = True,
191
+ http1: bool = True,
192
+ http2: bool = False,
193
+ limits: httpx.Limits = httpx.Limits(max_connections=100, max_keepalive_connections=20),
194
+ transport: httpx.BaseTransport | None = None,
195
+ **kwargs: t.Any,
196
+ ) -> httpx.BaseTransport:
197
+ if transport is not None:
198
+ return transport
199
+
200
+ return SyncCacheTransport(
201
+ next_transport=httpx.HTTPTransport(
202
+ verify=verify,
203
+ cert=cert,
204
+ trust_env=trust_env,
205
+ http1=http1,
206
+ http2=http2,
207
+ limits=limits,
208
+ ),
209
+ storage=self.storage,
210
+ policy=self.policy,
211
+ )
212
+
213
+ def _init_proxy_transport(
214
+ self,
215
+ proxy: httpx.Proxy,
216
+ verify: ssl.SSLContext | str | bool = True,
217
+ cert: t.Union[str, t.Tuple[str, str], t.Tuple[str, str, str], None] = None,
218
+ trust_env: bool = True,
219
+ http1: bool = True,
220
+ http2: bool = False,
221
+ limits: httpx.Limits = httpx.Limits(max_connections=100, max_keepalive_connections=20),
222
+ **kwargs: t.Any,
223
+ ) -> httpx.BaseTransport:
224
+ return SyncCacheTransport(
225
+ next_transport=httpx.HTTPTransport(
226
+ verify=verify,
227
+ cert=cert,
228
+ trust_env=trust_env,
229
+ http1=http1,
230
+ http2=http2,
231
+ limits=limits,
232
+ proxy=proxy,
233
+ ),
234
+ storage=self.storage,
235
+ policy=self.policy,
236
+ )
hishel/_utils.py CHANGED
@@ -3,7 +3,8 @@ from __future__ import annotations
3
3
  import calendar
4
4
  import time
5
5
  import typing as tp
6
- from email.utils import parsedate_tz
6
+ from email.utils import formatdate, parsedate_tz
7
+ from pathlib import Path
7
8
  from typing import AsyncIterator, Iterable, Iterator
8
9
 
9
10
  HEADERS_ENCODING = "iso-8859-1"
@@ -50,11 +51,35 @@ def partition(iterable: tp.Iterable[T], predicate: tp.Callable[[T], bool]) -> tp
50
51
  return matching, non_matching
51
52
 
52
53
 
53
- async def make_async_iterator(iterable: Iterable[bytes]) -> AsyncIterator[bytes]:
54
+ async def make_async_iterator(
55
+ iterable: Iterable[bytes],
56
+ ) -> AsyncIterator[bytes]:
54
57
  for item in iterable:
55
58
  yield item
56
59
 
57
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
+
58
83
  def make_sync_iterator(iterable: Iterable[bytes]) -> Iterator[bytes]:
59
84
  for item in iterable:
60
85
  yield item
@@ -80,3 +105,25 @@ def snake_to_header(text: str) -> str:
80
105
  """
81
106
  # Split by underscore, capitalize each word, join with dash, add X- prefix
82
107
  return "X-" + "-".join(word.capitalize() for word in text.split("_"))
108
+
109
+
110
+ def ensure_cache_dict(base_path: Path | None = None) -> Path:
111
+ _base_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)