hishel 1.1.7__tar.gz → 1.1.8__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 (26) hide show
  1. {hishel-1.1.7 → hishel-1.1.8}/CHANGELOG.md +14 -1
  2. {hishel-1.1.7 → hishel-1.1.8}/PKG-INFO +15 -2
  3. {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_storages/_async_sqlite.py +155 -138
  4. {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_storages/_sync_sqlite.py +155 -138
  5. {hishel-1.1.7 → hishel-1.1.8}/pyproject.toml +1 -1
  6. {hishel-1.1.7 → hishel-1.1.8}/.gitignore +0 -0
  7. {hishel-1.1.7 → hishel-1.1.8}/LICENSE +0 -0
  8. {hishel-1.1.7 → hishel-1.1.8}/README.md +0 -0
  9. {hishel-1.1.7 → hishel-1.1.8}/hishel/__init__.py +0 -0
  10. {hishel-1.1.7 → hishel-1.1.8}/hishel/_async_cache.py +0 -0
  11. {hishel-1.1.7 → hishel-1.1.8}/hishel/_async_httpx.py +0 -0
  12. {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_headers.py +0 -0
  13. {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_spec.py +0 -0
  14. {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_storages/_async_base.py +0 -0
  15. {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_storages/_packing.py +0 -0
  16. {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/_storages/_sync_base.py +0 -0
  17. {hishel-1.1.7 → hishel-1.1.8}/hishel/_core/models.py +0 -0
  18. {hishel-1.1.7 → hishel-1.1.8}/hishel/_policies.py +0 -0
  19. {hishel-1.1.7 → hishel-1.1.8}/hishel/_sync_cache.py +0 -0
  20. {hishel-1.1.7 → hishel-1.1.8}/hishel/_sync_httpx.py +0 -0
  21. {hishel-1.1.7 → hishel-1.1.8}/hishel/_utils.py +0 -0
  22. {hishel-1.1.7 → hishel-1.1.8}/hishel/asgi.py +0 -0
  23. {hishel-1.1.7 → hishel-1.1.8}/hishel/fastapi.py +0 -0
  24. {hishel-1.1.7 → hishel-1.1.8}/hishel/httpx.py +0 -0
  25. {hishel-1.1.7 → hishel-1.1.8}/hishel/py.typed +0 -0
  26. {hishel-1.1.7 → hishel-1.1.8}/hishel/requests.py +0 -0
@@ -1,3 +1,16 @@
1
+ ## What's Changed in 1.1.8
2
+ ### ⚙️ Miscellaneous Tasks
3
+
4
+ * chore(ci): remove redis action by @karpetrosyan in [#428](https://github.com/karpetrosyan/hishel/pull/428)
5
+ ### 🐛 Bug Fixes
6
+
7
+ * fix: prevent race conditions by @karpetrosyan in [#436](https://github.com/karpetrosyan/hishel/pull/436)
8
+
9
+ ### Contributors
10
+ * @karpetrosyan
11
+
12
+ **Full Changelog**: https://github.com/karpetrosyan/hishel/compare/1.1.7...1.1.8
13
+
1
14
  ## What's Changed in 1.1.7
2
15
  ### ♻️ Refactoring
3
16
 
@@ -16,11 +29,11 @@
16
29
  * Feature/accept pathlib path in SqliteStorage by @daudef in [#419](https://github.com/karpetrosyan/hishel/pull/419)
17
30
 
18
31
  ### Contributors
32
+ * @karpetrosyan
19
33
  * @daudef
20
34
  * @dependabot[bot]
21
35
  * @jeefberkey
22
36
  * @dump247
23
- * @karpetrosyan
24
37
 
25
38
  **Full Changelog**: https://github.com/karpetrosyan/hishel/compare/1.1.6...1.1.7
26
39
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hishel
3
- Version: 1.1.7
3
+ Version: 1.1.8
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
@@ -406,6 +406,19 @@ Hishel is inspired by and builds upon the excellent work in the Python HTTP ecos
406
406
  <strong>Made with ❤️ by <a href="https://github.com/karpetrosyan">Kar Petrosyan</a></strong>
407
407
  </p>
408
408
 
409
+ ## What's Changed in 1.1.8
410
+ ### ⚙️ Miscellaneous Tasks
411
+
412
+ * chore(ci): remove redis action by @karpetrosyan in [#428](https://github.com/karpetrosyan/hishel/pull/428)
413
+ ### 🐛 Bug Fixes
414
+
415
+ * fix: prevent race conditions by @karpetrosyan in [#436](https://github.com/karpetrosyan/hishel/pull/436)
416
+
417
+ ### Contributors
418
+ * @karpetrosyan
419
+
420
+ **Full Changelog**: https://github.com/karpetrosyan/hishel/compare/1.1.7...1.1.8
421
+
409
422
  ## What's Changed in 1.1.7
410
423
  ### ♻️ Refactoring
411
424
 
@@ -424,11 +437,11 @@ Hishel is inspired by and builds upon the excellent work in the Python HTTP ecos
424
437
  * Feature/accept pathlib path in SqliteStorage by @daudef in [#419](https://github.com/karpetrosyan/hishel/pull/419)
425
438
 
426
439
  ### Contributors
440
+ * @karpetrosyan
427
441
  * @daudef
428
442
  * @dependabot[bot]
429
443
  * @jeefberkey
430
444
  * @dump247
431
- * @karpetrosyan
432
445
 
433
446
  **Full Changelog**: https://github.com/karpetrosyan/hishel/compare/1.1.6...1.1.7
434
447
 
@@ -35,6 +35,7 @@ BATCH_CLEANUP_CHUNK_SIZE = 200
35
35
 
36
36
  try:
37
37
  import anysqlite
38
+ from anyio import Lock
38
39
 
39
40
  class AsyncSqliteStorage(AsyncBaseStorage):
40
41
  _COMPLETE_CHUNK_NUMBER = -1
@@ -55,9 +56,15 @@ try:
55
56
  # When this storage instance was created. Used to delay the first cleanup.
56
57
  self._start_time = time.time()
57
58
  self._initialized = False
59
+ self._lock = Lock()
58
60
 
59
61
  async def _ensure_connection(self) -> anysqlite.Connection:
60
- """Ensure connection is established and database is initialized."""
62
+ """
63
+ Ensure connection is established and database is initialized.
64
+
65
+ Note: This method assumes the caller has already acquired the lock.
66
+ """
67
+
61
68
  if self.connection is None:
62
69
  # Create cache directory and resolve full path on first connection
63
70
  parent = self.database_path.parent if self.database_path.parent != Path(".") else None
@@ -106,151 +113,156 @@ try:
106
113
  ) -> Entry:
107
114
  key_bytes = key.encode("utf-8")
108
115
 
109
- connection = await self._ensure_connection()
110
- cursor = await connection.cursor()
116
+ async with self._lock:
117
+ connection = await self._ensure_connection()
118
+ cursor = await connection.cursor()
111
119
 
112
- # Create a new entry directly with both request and response
113
- pair_id = id_ if id_ is not None else uuid.uuid4()
114
- pair_meta = EntryMeta(
115
- created_at=time.time(),
116
- )
120
+ # Create a new entry directly with both request and response
121
+ pair_id = id_ if id_ is not None else uuid.uuid4()
122
+ pair_meta = EntryMeta(
123
+ created_at=time.time(),
124
+ )
117
125
 
118
- assert isinstance(response.stream, (AsyncIterator, AsyncIterable))
119
- response_with_stream = replace(
120
- response,
121
- stream=self._save_stream(response.stream, pair_id.bytes),
122
- )
126
+ assert isinstance(response.stream, (AsyncIterator, AsyncIterable))
127
+ response_with_stream = replace(
128
+ response,
129
+ stream=self._save_stream_unlocked(response.stream, pair_id.bytes),
130
+ )
123
131
 
124
- complete_entry = Entry(
125
- id=pair_id,
126
- request=request,
127
- response=response_with_stream,
128
- meta=pair_meta,
129
- cache_key=key_bytes,
130
- )
132
+ complete_entry = Entry(
133
+ id=pair_id,
134
+ request=request,
135
+ response=response_with_stream,
136
+ meta=pair_meta,
137
+ cache_key=key_bytes,
138
+ )
131
139
 
132
- # Insert the complete entry into the database
133
- await cursor.execute(
134
- "INSERT INTO entries (id, cache_key, data, created_at, deleted_at) VALUES (?, ?, ?, ?, ?)",
135
- (pair_id.bytes, key_bytes, pack(complete_entry, kind="pair"), pair_meta.created_at, None),
136
- )
137
- await connection.commit()
140
+ # Insert the complete entry into the database
141
+ await cursor.execute(
142
+ "INSERT INTO entries (id, cache_key, data, created_at, deleted_at) VALUES (?, ?, ?, ?, ?)",
143
+ (pair_id.bytes, key_bytes, pack(complete_entry, kind="pair"), pair_meta.created_at, None),
144
+ )
145
+ await connection.commit()
138
146
 
139
- return complete_entry
147
+ return complete_entry
140
148
 
141
149
  async def get_entries(self, key: str) -> List[Entry]:
142
150
  final_pairs: List[Entry] = []
143
151
 
144
152
  now = time.time()
145
- if now - self.last_cleanup >= BATCH_CLEANUP_INTERVAL:
146
- try:
147
- await self._batch_cleanup()
148
- except Exception:
149
- # don't let cleanup prevent reads; failures are non-fatal
150
- pass
153
+ async with self._lock:
154
+ if now - self.last_cleanup >= BATCH_CLEANUP_INTERVAL:
155
+ try:
156
+ await self._batch_cleanup()
157
+ except Exception:
158
+ # don't let cleanup prevent reads; failures are non-fatal
159
+ pass
151
160
 
152
- connection = await self._ensure_connection()
153
- cursor = await connection.cursor()
154
- # Query entries directly by cache_key
155
- await cursor.execute(
156
- "SELECT id, data FROM entries WHERE cache_key = ?",
157
- (key.encode("utf-8"),),
158
- )
161
+ connection = await self._ensure_connection()
162
+ cursor = await connection.cursor()
163
+ # Query entries directly by cache_key
164
+ await cursor.execute(
165
+ "SELECT id, data FROM entries WHERE cache_key = ?",
166
+ (key.encode("utf-8"),),
167
+ )
159
168
 
160
- for row in await cursor.fetchall():
161
- pair_data = unpack(row[1], kind="pair")
169
+ for row in await cursor.fetchall():
170
+ pair_data = unpack(row[1], kind="pair")
162
171
 
163
- if pair_data is None:
164
- continue
172
+ if pair_data is None:
173
+ continue
165
174
 
166
- # Skip entries without a response (incomplete)
167
- if not await self._is_stream_complete(pair_data.id, cursor=cursor):
168
- continue
169
-
170
- # Skip expired entries
171
- if await self._is_pair_expired(pair_data, cursor=cursor):
172
- continue
173
-
174
- # Skip soft-deleted entries
175
- if self.is_soft_deleted(pair_data):
176
- continue
177
-
178
- final_pairs.append(pair_data)
179
-
180
- pairs_with_streams: List[Entry] = []
181
-
182
- # Only restore response streams from cache
183
- for pair in final_pairs:
184
- pairs_with_streams.append(
185
- replace(
186
- pair,
187
- response=replace(
188
- pair.response,
189
- stream=self._stream_data_from_cache(pair.id.bytes),
190
- ),
175
+ # Skip entries without a response (incomplete)
176
+ if not await self._is_stream_complete(pair_data.id, cursor=cursor):
177
+ continue
178
+
179
+ # Skip expired entries
180
+ if await self._is_pair_expired(pair_data, cursor=cursor):
181
+ continue
182
+
183
+ # Skip soft-deleted entries
184
+ if self.is_soft_deleted(pair_data):
185
+ continue
186
+
187
+ final_pairs.append(pair_data)
188
+
189
+ pairs_with_streams: List[Entry] = []
190
+
191
+ # Only restore response streams from cache
192
+ for pair in final_pairs:
193
+ pairs_with_streams.append(
194
+ replace(
195
+ pair,
196
+ response=replace(
197
+ pair.response,
198
+ stream=self._stream_data_from_cache(pair.id.bytes),
199
+ ),
200
+ )
191
201
  )
192
- )
193
- return pairs_with_streams
202
+ return pairs_with_streams
194
203
 
195
204
  async def update_entry(
196
205
  self,
197
206
  id: uuid.UUID,
198
207
  new_pair: Union[Entry, Callable[[Entry], Entry]],
199
208
  ) -> Optional[Entry]:
200
- connection = await self._ensure_connection()
201
- cursor = await connection.cursor()
202
- await cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
203
- result = await cursor.fetchone()
204
-
205
- if result is None:
206
- return None
209
+ async with self._lock:
210
+ connection = await self._ensure_connection()
211
+ cursor = await connection.cursor()
212
+ await cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
213
+ result = await cursor.fetchone()
207
214
 
208
- pair = unpack(result[0], kind="pair")
215
+ if result is None:
216
+ return None
209
217
 
210
- # Skip entries without a response (incomplete)
211
- if not isinstance(pair, Entry) or pair.response is None:
212
- return None
218
+ pair = unpack(result[0], kind="pair")
213
219
 
214
- if isinstance(new_pair, Entry):
215
- complete_pair = new_pair
216
- else:
217
- complete_pair = new_pair(pair)
220
+ # Skip entries without a response (incomplete)
221
+ if not isinstance(pair, Entry) or pair.response is None:
222
+ return None
218
223
 
219
- if pair.id != complete_pair.id:
220
- raise ValueError("Pair ID mismatch")
224
+ if isinstance(new_pair, Entry):
225
+ complete_pair = new_pair
226
+ else:
227
+ complete_pair = new_pair(pair)
221
228
 
222
- await cursor.execute(
223
- "UPDATE entries SET data = ? WHERE id = ?",
224
- (pack(complete_pair, kind="pair"), id.bytes),
225
- )
229
+ if pair.id != complete_pair.id:
230
+ raise ValueError("Pair ID mismatch")
226
231
 
227
- if pair.cache_key != complete_pair.cache_key:
228
232
  await cursor.execute(
229
- "UPDATE entries SET cache_key = ? WHERE id = ?",
230
- (complete_pair.cache_key, complete_pair.id.bytes),
233
+ "UPDATE entries SET data = ? WHERE id = ?",
234
+ (pack(complete_pair, kind="pair"), id.bytes),
231
235
  )
232
236
 
233
- await connection.commit()
237
+ if pair.cache_key != complete_pair.cache_key:
238
+ await cursor.execute(
239
+ "UPDATE entries SET cache_key = ? WHERE id = ?",
240
+ (complete_pair.cache_key, complete_pair.id.bytes),
241
+ )
242
+
243
+ await connection.commit()
234
244
 
235
- return complete_pair
245
+ return complete_pair
236
246
 
237
247
  async def remove_entry(self, id: uuid.UUID) -> None:
238
- connection = await self._ensure_connection()
239
- cursor = await connection.cursor()
240
- await cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
241
- result = await cursor.fetchone()
248
+ async with self._lock:
249
+ connection = await self._ensure_connection()
250
+ cursor = await connection.cursor()
251
+ await cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
252
+ result = await cursor.fetchone()
242
253
 
243
- if result is None:
244
- return None
254
+ if result is None:
255
+ return None
245
256
 
246
- pair = unpack(result[0], kind="pair")
247
- await self._soft_delete_pair(pair, cursor)
248
- await connection.commit()
257
+ pair = unpack(result[0], kind="pair")
258
+ await self._soft_delete_pair(pair, cursor)
259
+ await connection.commit()
249
260
 
250
261
  async def close(self) -> None:
251
- if self.connection is not None:
252
- await self.connection.close()
253
- self.connection = None
262
+ async with self._lock:
263
+ if self.connection is not None:
264
+ await self.connection.close()
265
+ self.connection = None
254
266
 
255
267
  async def _is_stream_complete(self, pair_id: uuid.UUID, cursor: anysqlite.Cursor) -> bool:
256
268
  # Check if there's a completion marker (chunk_number = -1) for response stream
@@ -363,36 +375,40 @@ try:
363
375
  """
364
376
  await cursor.execute("DELETE FROM streams WHERE entry_id = ?", (entry_id,))
365
377
 
366
- async def _save_stream(
378
+ async def _save_stream_unlocked(
367
379
  self,
368
380
  stream: AsyncIterator[bytes],
369
381
  entry_id: bytes,
370
382
  ) -> AsyncIterator[bytes]:
371
383
  """
372
384
  Wrapper around an async iterator that also saves the response data to the cache in chunks.
385
+
386
+ Note: This method assumes the caller has already acquired the lock.
373
387
  """
374
388
  chunk_number = 0
375
389
  content_length = 0
376
390
  async for chunk in stream:
377
391
  content_length += len(chunk)
392
+ async with self._lock:
393
+ connection = await self._ensure_connection()
394
+ cursor = await connection.cursor()
395
+ await cursor.execute(
396
+ "INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
397
+ (entry_id, chunk_number, chunk),
398
+ )
399
+ await connection.commit()
400
+ chunk_number += 1
401
+ yield chunk
402
+
403
+ async with self._lock:
404
+ # Mark end of stream with chunk_number = -1
378
405
  connection = await self._ensure_connection()
379
406
  cursor = await connection.cursor()
380
407
  await cursor.execute(
381
408
  "INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
382
- (entry_id, chunk_number, chunk),
409
+ (entry_id, self._COMPLETE_CHUNK_NUMBER, b""),
383
410
  )
384
411
  await connection.commit()
385
- chunk_number += 1
386
- yield chunk
387
-
388
- # Mark end of stream with chunk_number = -1
389
- connection = await self._ensure_connection()
390
- cursor = await connection.cursor()
391
- await cursor.execute(
392
- "INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
393
- (entry_id, self._COMPLETE_CHUNK_NUMBER, b""),
394
- )
395
- await connection.commit()
396
412
 
397
413
  async def _stream_data_from_cache(
398
414
  self,
@@ -403,23 +419,24 @@ try:
403
419
  """
404
420
  chunk_number = 0
405
421
 
406
- connection = await self._ensure_connection()
407
422
  while True:
408
- cursor = await connection.cursor()
409
- await cursor.execute(
410
- "SELECT chunk_data FROM streams WHERE entry_id = ? AND chunk_number = ?",
411
- (entry_id, chunk_number),
412
- )
413
- result = await cursor.fetchone()
414
-
415
- if result is None:
416
- break
417
- chunk = result[0]
418
- # chunk_number = -1 is the completion marker with empty data
419
- if chunk == b"":
420
- break
421
- yield chunk
422
- chunk_number += 1
423
+ async with self._lock:
424
+ connection = await self._ensure_connection()
425
+ cursor = await connection.cursor()
426
+ await cursor.execute(
427
+ "SELECT chunk_data FROM streams WHERE entry_id = ? AND chunk_number = ?",
428
+ (entry_id, chunk_number),
429
+ )
430
+ result = await cursor.fetchone()
431
+
432
+ if result is None:
433
+ break
434
+ chunk = result[0]
435
+ # chunk_number = -1 is the completion marker with empty data
436
+ if chunk == b"":
437
+ break
438
+ yield chunk
439
+ chunk_number += 1
423
440
 
424
441
  except ImportError:
425
442
 
@@ -35,6 +35,7 @@ BATCH_CLEANUP_CHUNK_SIZE = 200
35
35
 
36
36
  try:
37
37
  import sqlite3
38
+ from threading import RLock
38
39
 
39
40
  class SyncSqliteStorage(SyncBaseStorage):
40
41
  _COMPLETE_CHUNK_NUMBER = -1
@@ -55,9 +56,15 @@ try:
55
56
  # When this storage instance was created. Used to delay the first cleanup.
56
57
  self._start_time = time.time()
57
58
  self._initialized = False
59
+ self._lock = RLock()
58
60
 
59
61
  def _ensure_connection(self) -> sqlite3.Connection:
60
- """Ensure connection is established and database is initialized."""
62
+ """
63
+ Ensure connection is established and database is initialized.
64
+
65
+ Note: This method assumes the caller has already acquired the lock.
66
+ """
67
+
61
68
  if self.connection is None:
62
69
  # Create cache directory and resolve full path on first connection
63
70
  parent = self.database_path.parent if self.database_path.parent != Path(".") else None
@@ -106,151 +113,156 @@ try:
106
113
  ) -> Entry:
107
114
  key_bytes = key.encode("utf-8")
108
115
 
109
- connection = self._ensure_connection()
110
- cursor = connection.cursor()
116
+ with self._lock:
117
+ connection = self._ensure_connection()
118
+ cursor = connection.cursor()
111
119
 
112
- # Create a new entry directly with both request and response
113
- pair_id = id_ if id_ is not None else uuid.uuid4()
114
- pair_meta = EntryMeta(
115
- created_at=time.time(),
116
- )
120
+ # Create a new entry directly with both request and response
121
+ pair_id = id_ if id_ is not None else uuid.uuid4()
122
+ pair_meta = EntryMeta(
123
+ created_at=time.time(),
124
+ )
117
125
 
118
- assert isinstance(response.stream, (Iterator, Iterable))
119
- response_with_stream = replace(
120
- response,
121
- stream=self._save_stream(response.stream, pair_id.bytes),
122
- )
126
+ assert isinstance(response.stream, (Iterator, Iterable))
127
+ response_with_stream = replace(
128
+ response,
129
+ stream=self._save_stream_unlocked(response.stream, pair_id.bytes),
130
+ )
123
131
 
124
- complete_entry = Entry(
125
- id=pair_id,
126
- request=request,
127
- response=response_with_stream,
128
- meta=pair_meta,
129
- cache_key=key_bytes,
130
- )
132
+ complete_entry = Entry(
133
+ id=pair_id,
134
+ request=request,
135
+ response=response_with_stream,
136
+ meta=pair_meta,
137
+ cache_key=key_bytes,
138
+ )
131
139
 
132
- # Insert the complete entry into the database
133
- cursor.execute(
134
- "INSERT INTO entries (id, cache_key, data, created_at, deleted_at) VALUES (?, ?, ?, ?, ?)",
135
- (pair_id.bytes, key_bytes, pack(complete_entry, kind="pair"), pair_meta.created_at, None),
136
- )
137
- connection.commit()
140
+ # Insert the complete entry into the database
141
+ cursor.execute(
142
+ "INSERT INTO entries (id, cache_key, data, created_at, deleted_at) VALUES (?, ?, ?, ?, ?)",
143
+ (pair_id.bytes, key_bytes, pack(complete_entry, kind="pair"), pair_meta.created_at, None),
144
+ )
145
+ connection.commit()
138
146
 
139
- return complete_entry
147
+ return complete_entry
140
148
 
141
149
  def get_entries(self, key: str) -> List[Entry]:
142
150
  final_pairs: List[Entry] = []
143
151
 
144
152
  now = time.time()
145
- if now - self.last_cleanup >= BATCH_CLEANUP_INTERVAL:
146
- try:
147
- self._batch_cleanup()
148
- except Exception:
149
- # don't let cleanup prevent reads; failures are non-fatal
150
- pass
153
+ with self._lock:
154
+ if now - self.last_cleanup >= BATCH_CLEANUP_INTERVAL:
155
+ try:
156
+ self._batch_cleanup()
157
+ except Exception:
158
+ # don't let cleanup prevent reads; failures are non-fatal
159
+ pass
151
160
 
152
- connection = self._ensure_connection()
153
- cursor = connection.cursor()
154
- # Query entries directly by cache_key
155
- cursor.execute(
156
- "SELECT id, data FROM entries WHERE cache_key = ?",
157
- (key.encode("utf-8"),),
158
- )
161
+ connection = self._ensure_connection()
162
+ cursor = connection.cursor()
163
+ # Query entries directly by cache_key
164
+ cursor.execute(
165
+ "SELECT id, data FROM entries WHERE cache_key = ?",
166
+ (key.encode("utf-8"),),
167
+ )
159
168
 
160
- for row in cursor.fetchall():
161
- pair_data = unpack(row[1], kind="pair")
169
+ for row in cursor.fetchall():
170
+ pair_data = unpack(row[1], kind="pair")
162
171
 
163
- if pair_data is None:
164
- continue
172
+ if pair_data is None:
173
+ continue
165
174
 
166
- # Skip entries without a response (incomplete)
167
- if not self._is_stream_complete(pair_data.id, cursor=cursor):
168
- continue
169
-
170
- # Skip expired entries
171
- if self._is_pair_expired(pair_data, cursor=cursor):
172
- continue
173
-
174
- # Skip soft-deleted entries
175
- if self.is_soft_deleted(pair_data):
176
- continue
177
-
178
- final_pairs.append(pair_data)
179
-
180
- pairs_with_streams: List[Entry] = []
181
-
182
- # Only restore response streams from cache
183
- for pair in final_pairs:
184
- pairs_with_streams.append(
185
- replace(
186
- pair,
187
- response=replace(
188
- pair.response,
189
- stream=self._stream_data_from_cache(pair.id.bytes),
190
- ),
175
+ # Skip entries without a response (incomplete)
176
+ if not self._is_stream_complete(pair_data.id, cursor=cursor):
177
+ continue
178
+
179
+ # Skip expired entries
180
+ if self._is_pair_expired(pair_data, cursor=cursor):
181
+ continue
182
+
183
+ # Skip soft-deleted entries
184
+ if self.is_soft_deleted(pair_data):
185
+ continue
186
+
187
+ final_pairs.append(pair_data)
188
+
189
+ pairs_with_streams: List[Entry] = []
190
+
191
+ # Only restore response streams from cache
192
+ for pair in final_pairs:
193
+ pairs_with_streams.append(
194
+ replace(
195
+ pair,
196
+ response=replace(
197
+ pair.response,
198
+ stream=self._stream_data_from_cache(pair.id.bytes),
199
+ ),
200
+ )
191
201
  )
192
- )
193
- return pairs_with_streams
202
+ return pairs_with_streams
194
203
 
195
204
  def update_entry(
196
205
  self,
197
206
  id: uuid.UUID,
198
207
  new_pair: Union[Entry, Callable[[Entry], Entry]],
199
208
  ) -> Optional[Entry]:
200
- connection = self._ensure_connection()
201
- cursor = connection.cursor()
202
- cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
203
- result = cursor.fetchone()
204
-
205
- if result is None:
206
- return None
209
+ with self._lock:
210
+ connection = self._ensure_connection()
211
+ cursor = connection.cursor()
212
+ cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
213
+ result = cursor.fetchone()
207
214
 
208
- pair = unpack(result[0], kind="pair")
215
+ if result is None:
216
+ return None
209
217
 
210
- # Skip entries without a response (incomplete)
211
- if not isinstance(pair, Entry) or pair.response is None:
212
- return None
218
+ pair = unpack(result[0], kind="pair")
213
219
 
214
- if isinstance(new_pair, Entry):
215
- complete_pair = new_pair
216
- else:
217
- complete_pair = new_pair(pair)
220
+ # Skip entries without a response (incomplete)
221
+ if not isinstance(pair, Entry) or pair.response is None:
222
+ return None
218
223
 
219
- if pair.id != complete_pair.id:
220
- raise ValueError("Pair ID mismatch")
224
+ if isinstance(new_pair, Entry):
225
+ complete_pair = new_pair
226
+ else:
227
+ complete_pair = new_pair(pair)
221
228
 
222
- cursor.execute(
223
- "UPDATE entries SET data = ? WHERE id = ?",
224
- (pack(complete_pair, kind="pair"), id.bytes),
225
- )
229
+ if pair.id != complete_pair.id:
230
+ raise ValueError("Pair ID mismatch")
226
231
 
227
- if pair.cache_key != complete_pair.cache_key:
228
232
  cursor.execute(
229
- "UPDATE entries SET cache_key = ? WHERE id = ?",
230
- (complete_pair.cache_key, complete_pair.id.bytes),
233
+ "UPDATE entries SET data = ? WHERE id = ?",
234
+ (pack(complete_pair, kind="pair"), id.bytes),
231
235
  )
232
236
 
233
- connection.commit()
237
+ if pair.cache_key != complete_pair.cache_key:
238
+ cursor.execute(
239
+ "UPDATE entries SET cache_key = ? WHERE id = ?",
240
+ (complete_pair.cache_key, complete_pair.id.bytes),
241
+ )
242
+
243
+ connection.commit()
234
244
 
235
- return complete_pair
245
+ return complete_pair
236
246
 
237
247
  def remove_entry(self, id: uuid.UUID) -> None:
238
- connection = self._ensure_connection()
239
- cursor = connection.cursor()
240
- cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
241
- result = cursor.fetchone()
248
+ with self._lock:
249
+ connection = self._ensure_connection()
250
+ cursor = connection.cursor()
251
+ cursor.execute("SELECT data FROM entries WHERE id = ?", (id.bytes,))
252
+ result = cursor.fetchone()
242
253
 
243
- if result is None:
244
- return None
254
+ if result is None:
255
+ return None
245
256
 
246
- pair = unpack(result[0], kind="pair")
247
- self._soft_delete_pair(pair, cursor)
248
- connection.commit()
257
+ pair = unpack(result[0], kind="pair")
258
+ self._soft_delete_pair(pair, cursor)
259
+ connection.commit()
249
260
 
250
261
  def close(self) -> None:
251
- if self.connection is not None:
252
- self.connection.close()
253
- self.connection = None
262
+ with self._lock:
263
+ if self.connection is not None:
264
+ self.connection.close()
265
+ self.connection = None
254
266
 
255
267
  def _is_stream_complete(self, pair_id: uuid.UUID, cursor: sqlite3.Cursor) -> bool:
256
268
  # Check if there's a completion marker (chunk_number = -1) for response stream
@@ -363,36 +375,40 @@ try:
363
375
  """
364
376
  cursor.execute("DELETE FROM streams WHERE entry_id = ?", (entry_id,))
365
377
 
366
- def _save_stream(
378
+ def _save_stream_unlocked(
367
379
  self,
368
380
  stream: Iterator[bytes],
369
381
  entry_id: bytes,
370
382
  ) -> Iterator[bytes]:
371
383
  """
372
384
  Wrapper around an async iterator that also saves the response data to the cache in chunks.
385
+
386
+ Note: This method assumes the caller has already acquired the lock.
373
387
  """
374
388
  chunk_number = 0
375
389
  content_length = 0
376
390
  for chunk in stream:
377
391
  content_length += len(chunk)
392
+ with self._lock:
393
+ connection = self._ensure_connection()
394
+ cursor = connection.cursor()
395
+ cursor.execute(
396
+ "INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
397
+ (entry_id, chunk_number, chunk),
398
+ )
399
+ connection.commit()
400
+ chunk_number += 1
401
+ yield chunk
402
+
403
+ with self._lock:
404
+ # Mark end of stream with chunk_number = -1
378
405
  connection = self._ensure_connection()
379
406
  cursor = connection.cursor()
380
407
  cursor.execute(
381
408
  "INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
382
- (entry_id, chunk_number, chunk),
409
+ (entry_id, self._COMPLETE_CHUNK_NUMBER, b""),
383
410
  )
384
411
  connection.commit()
385
- chunk_number += 1
386
- yield chunk
387
-
388
- # Mark end of stream with chunk_number = -1
389
- connection = self._ensure_connection()
390
- cursor = connection.cursor()
391
- cursor.execute(
392
- "INSERT INTO streams (entry_id, chunk_number, chunk_data) VALUES (?, ?, ?)",
393
- (entry_id, self._COMPLETE_CHUNK_NUMBER, b""),
394
- )
395
- connection.commit()
396
412
 
397
413
  def _stream_data_from_cache(
398
414
  self,
@@ -403,23 +419,24 @@ try:
403
419
  """
404
420
  chunk_number = 0
405
421
 
406
- connection = self._ensure_connection()
407
422
  while True:
408
- cursor = connection.cursor()
409
- cursor.execute(
410
- "SELECT chunk_data FROM streams WHERE entry_id = ? AND chunk_number = ?",
411
- (entry_id, chunk_number),
412
- )
413
- result = cursor.fetchone()
414
-
415
- if result is None:
416
- break
417
- chunk = result[0]
418
- # chunk_number = -1 is the completion marker with empty data
419
- if chunk == b"":
420
- break
421
- yield chunk
422
- chunk_number += 1
423
+ with self._lock:
424
+ connection = self._ensure_connection()
425
+ cursor = connection.cursor()
426
+ cursor.execute(
427
+ "SELECT chunk_data FROM streams WHERE entry_id = ? AND chunk_number = ?",
428
+ (entry_id, chunk_number),
429
+ )
430
+ result = cursor.fetchone()
431
+
432
+ if result is None:
433
+ break
434
+ chunk = result[0]
435
+ # chunk_number = -1 is the completion marker with empty data
436
+ if chunk == b"":
437
+ break
438
+ yield chunk
439
+ chunk_number += 1
423
440
 
424
441
  except ImportError:
425
442
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hishel"
7
- version = "1.1.7"
7
+ version = "1.1.8"
8
8
  dynamic = ["readme"]
9
9
  description = " Elegant HTTP Caching for Python"
10
10
  license = "BSD-3-Clause"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes