1
1
import asyncio
2
+ import io
2
3
import os
3
4
import pathlib
4
5
import sys
16
17
Iterator ,
17
18
List ,
18
19
Optional ,
20
+ Set ,
19
21
Tuple ,
20
22
Union ,
21
23
cast ,
73
75
CONTENT_TYPES .add_type (content_type , extension ) # type: ignore[attr-defined]
74
76
75
77
78
+ _CLOSE_FUTURES : Set [asyncio .Future [None ]] = set ()
79
+
80
+
76
81
class FileResponse (StreamResponse ):
77
82
"""A response object can be used to send files."""
78
83
@@ -161,10 +166,10 @@ async def _precondition_failed(
161
166
self .content_length = 0
162
167
return await super ().prepare (request )
163
168
164
- def _get_file_path_stat_encoding (
169
+ def _open_file_path_stat_encoding (
165
170
self , accept_encoding : str
166
- ) -> Tuple [pathlib . Path , os .stat_result , Optional [str ]]:
167
- """Return the file path , stat result, and encoding.
171
+ ) -> Tuple [Optional [ io . BufferedReader ] , os .stat_result , Optional [str ]]:
172
+ """Return the io object , stat result, and encoding.
168
173
169
174
If an uncompressed file is returned, the encoding is set to
170
175
:py:data:`None`.
@@ -182,31 +187,72 @@ def _get_file_path_stat_encoding(
182
187
# Do not follow symlinks and ignore any non-regular files.
183
188
st = compressed_path .lstat ()
184
189
if S_ISREG (st .st_mode ):
185
- return compressed_path , st , file_encoding
190
+ fobj = compressed_path .open ("rb" )
191
+ with suppress (OSError ):
192
+ # fstat() may not be available on all platforms
193
+ # Once we open the file, we want the fstat() to ensure
194
+ # the file has not changed between the first stat()
195
+ # and the open().
196
+ st = os .stat (fobj .fileno ())
197
+ return fobj , st , file_encoding
186
198
187
199
# Fallback to the uncompressed file
188
- return file_path , file_path .stat (), None
200
+ st = file_path .stat ()
201
+ if not S_ISREG (st .st_mode ):
202
+ return None , st , None
203
+ fobj = file_path .open ("rb" )
204
+ with suppress (OSError ):
205
+ # fstat() may not be available on all platforms
206
+ # Once we open the file, we want the fstat() to ensure
207
+ # the file has not changed between the first stat()
208
+ # and the open().
209
+ st = os .stat (fobj .fileno ())
210
+ return fobj , st , None
189
211
190
212
async def prepare (self , request : "BaseRequest" ) -> Optional [AbstractStreamWriter ]:
191
213
loop = asyncio .get_running_loop ()
192
214
# Encoding comparisons should be case-insensitive
193
215
# https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1
194
216
accept_encoding = request .headers .get (hdrs .ACCEPT_ENCODING , "" ).lower ()
195
217
try :
196
- file_path , st , file_encoding = await loop .run_in_executor (
197
- None , self ._get_file_path_stat_encoding , accept_encoding
218
+ fobj , st , file_encoding = await loop .run_in_executor (
219
+ None , self ._open_file_path_stat_encoding , accept_encoding
198
220
)
221
+ except PermissionError :
222
+ self .set_status (HTTPForbidden .status_code )
223
+ return await super ().prepare (request )
199
224
except OSError :
200
225
# Most likely to be FileNotFoundError or OSError for circular
201
226
# symlinks in python >= 3.13, so respond with 404.
202
227
self .set_status (HTTPNotFound .status_code )
203
228
return await super ().prepare (request )
204
229
205
- # Forbid special files like sockets, pipes, devices, etc.
206
- if not S_ISREG (st .st_mode ):
207
- self .set_status (HTTPForbidden .status_code )
208
- return await super ().prepare (request )
230
+ try :
231
+ # Forbid special files like sockets, pipes, devices, etc.
232
+ if not fobj or not S_ISREG (st .st_mode ):
233
+ self .set_status (HTTPForbidden .status_code )
234
+ return await super ().prepare (request )
209
235
236
+ return await self ._prepare_open_file (request , fobj , st , file_encoding )
237
+ finally :
238
+ if fobj :
239
+ # We do not await here because we do not want to wait
240
+ # for the executor to finish before returning the response
241
+ # so the connection can begin servicing another request
242
+ # as soon as possible.
243
+ close_future = loop .run_in_executor (None , fobj .close )
244
+ # Hold a strong reference to the future to prevent it from being
245
+ # garbage collected before it completes.
246
+ _CLOSE_FUTURES .add (close_future )
247
+ close_future .add_done_callback (_CLOSE_FUTURES .remove )
248
+
249
+ async def _prepare_open_file (
250
+ self ,
251
+ request : "BaseRequest" ,
252
+ fobj : io .BufferedReader ,
253
+ st : os .stat_result ,
254
+ file_encoding : Optional [str ],
255
+ ) -> Optional [AbstractStreamWriter ]:
210
256
etag_value = f"{ st .st_mtime_ns :x} -{ st .st_size :x} "
211
257
last_modified = st .st_mtime
212
258
@@ -349,18 +395,9 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
349
395
if count == 0 or must_be_empty_body (request .method , self .status ):
350
396
return await super ().prepare (request )
351
397
352
- try :
353
- fobj = await loop .run_in_executor (None , file_path .open , "rb" )
354
- except PermissionError :
355
- self .set_status (HTTPForbidden .status_code )
356
- return await super ().prepare (request )
357
-
358
398
if start : # be aware that start could be None or int=0 here.
359
399
offset = start
360
400
else :
361
401
offset = 0
362
402
363
- try :
364
- return await self ._sendfile (request , fobj , offset , count )
365
- finally :
366
- await asyncio .shield (loop .run_in_executor (None , fobj .close ))
403
+ return await self ._sendfile (request , fobj , offset , count )
0 commit comments