4
4
import pathlib
5
5
import sys
6
6
from contextlib import suppress
7
+ from enum import Enum , auto
7
8
from mimetypes import MimeTypes
8
9
from stat import S_ISREG
9
10
from types import MappingProxyType
69
70
}
70
71
)
71
72
73
+
74
+ class _FileResponseResult (Enum ):
75
+ """The result of the file response."""
76
+
77
+ SEND_FILE = auto () # Ie a regular file to send
78
+ NOT_ACCEPTABLE = auto () # Ie a socket, or non-regular file
79
+ PRE_CONDITION_FAILED = auto () # Ie If-Match or If-None-Match failed
80
+ NOT_MODIFIED = auto () # 304 Not Modified
81
+
82
+
72
83
# Add custom pairs and clear the encodings map so guess_type ignores them.
73
84
CONTENT_TYPES .encodings_map .clear ()
74
85
for content_type , extension in ADDITIONAL_CONTENT_TYPES .items ():
@@ -166,17 +177,65 @@ async def _precondition_failed(
166
177
self .content_length = 0
167
178
return await super ().prepare (request )
168
179
169
- def _open_file_path_stat_encoding (
170
- self , accept_encoding : str
171
- ) -> Tuple [Optional [io .BufferedReader ], os .stat_result , Optional [str ]]:
172
- """Return the io object, stat result, and encoding.
180
+ def _make_response (
181
+ self , request : "BaseRequest" , accept_encoding : str
182
+ ) -> Tuple [
183
+ _FileResponseResult , Optional [io .BufferedReader ], os .stat_result , Optional [str ]
184
+ ]:
185
+ """Return the response result, io object, stat result, and encoding.
173
186
174
187
If an uncompressed file is returned, the encoding is set to
175
188
:py:data:`None`.
176
189
177
190
This method should be called from a thread executor
178
191
since it calls os.stat which may block.
179
192
"""
193
+ file_path , st , file_encoding = self ._get_file_path_stat_encoding (
194
+ accept_encoding
195
+ )
196
+ if not file_path :
197
+ return _FileResponseResult .NOT_ACCEPTABLE , None , st , None
198
+
199
+ etag_value = f"{ st .st_mtime_ns :x} -{ st .st_size :x} "
200
+
201
+ # https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1-2
202
+ if (ifmatch := request .if_match ) is not None and not self ._etag_match (
203
+ etag_value , ifmatch , weak = False
204
+ ):
205
+ return _FileResponseResult .PRE_CONDITION_FAILED , None , st , file_encoding
206
+
207
+ if (
208
+ (unmodsince := request .if_unmodified_since ) is not None
209
+ and ifmatch is None
210
+ and st .st_mtime > unmodsince .timestamp ()
211
+ ):
212
+ return _FileResponseResult .PRE_CONDITION_FAILED , None , st , file_encoding
213
+
214
+ # https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2-2
215
+ if (ifnonematch := request .if_none_match ) is not None and self ._etag_match (
216
+ etag_value , ifnonematch , weak = True
217
+ ):
218
+ return _FileResponseResult .NOT_MODIFIED , None , st , file_encoding
219
+
220
+ if (
221
+ (modsince := request .if_modified_since ) is not None
222
+ and ifnonematch is None
223
+ and st .st_mtime <= modsince .timestamp ()
224
+ ):
225
+ return _FileResponseResult .NOT_MODIFIED , None , st , file_encoding
226
+
227
+ fobj = file_path .open ("rb" )
228
+ with suppress (OSError ):
229
+ # fstat() may not be available on all platforms
230
+ # Once we open the file, we want the fstat() to ensure
231
+ # the file has not changed between the first stat()
232
+ # and the open().
233
+ st = os .stat (fobj .fileno ())
234
+ return _FileResponseResult .SEND_FILE , fobj , st , file_encoding
235
+
236
+ def _get_file_path_stat_encoding (
237
+ self , accept_encoding : str
238
+ ) -> Tuple [Optional [pathlib .Path ], os .stat_result , Optional [str ]]:
180
239
file_path = self ._path
181
240
for file_extension , file_encoding in ENCODING_EXTENSIONS .items ():
182
241
if file_encoding not in accept_encoding :
@@ -187,36 +246,22 @@ def _open_file_path_stat_encoding(
187
246
# Do not follow symlinks and ignore any non-regular files.
188
247
st = compressed_path .lstat ()
189
248
if S_ISREG (st .st_mode ):
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
249
+ return compressed_path , st , file_encoding
198
250
199
251
# Fallback to the uncompressed file
200
252
st = file_path .stat ()
201
253
if not S_ISREG (st .st_mode ):
202
254
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
255
+ return file_path , st , None
211
256
212
257
async def prepare (self , request : "BaseRequest" ) -> Optional [AbstractStreamWriter ]:
213
258
loop = asyncio .get_running_loop ()
214
259
# Encoding comparisons should be case-insensitive
215
260
# https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1
216
261
accept_encoding = request .headers .get (hdrs .ACCEPT_ENCODING , "" ).lower ()
217
262
try :
218
- fobj , st , file_encoding = await loop .run_in_executor (
219
- None , self ._open_file_path_stat_encoding , accept_encoding
263
+ response_result , fobj , st , file_encoding = await loop .run_in_executor (
264
+ None , self ._make_response , request , accept_encoding
220
265
)
221
266
except PermissionError :
222
267
self .set_status (HTTPForbidden .status_code )
@@ -227,24 +272,32 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
227
272
self .set_status (HTTPNotFound .status_code )
228
273
return await super ().prepare (request )
229
274
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 )
275
+ # Forbid special files like sockets, pipes, devices, etc.
276
+ if response_result is _FileResponseResult .NOT_ACCEPTABLE :
277
+ self .set_status (HTTPForbidden .status_code )
278
+ return await super ().prepare (request )
279
+
280
+ if response_result is _FileResponseResult .PRE_CONDITION_FAILED :
281
+ return await self ._precondition_failed (request )
235
282
283
+ if response_result is _FileResponseResult .NOT_MODIFIED :
284
+ etag_value = f"{ st .st_mtime_ns :x} -{ st .st_size :x} "
285
+ last_modified = st .st_mtime
286
+ return await self ._not_modified (request , etag_value , last_modified )
287
+
288
+ assert fobj is not None
289
+ try :
236
290
return await self ._prepare_open_file (request , fobj , st , file_encoding )
237
291
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 )
292
+ # We do not await here because we do not want to wait
293
+ # for the executor to finish before returning the response
294
+ # so the connection can begin servicing another request
295
+ # as soon as possible.
296
+ close_future = loop .run_in_executor (None , fobj .close )
297
+ # Hold a strong reference to the future to prevent it from being
298
+ # garbage collected before it completes.
299
+ _CLOSE_FUTURES .add (close_future )
300
+ close_future .add_done_callback (_CLOSE_FUTURES .remove )
248
301
249
302
async def _prepare_open_file (
250
303
self ,
@@ -253,43 +306,9 @@ async def _prepare_open_file(
253
306
st : os .stat_result ,
254
307
file_encoding : Optional [str ],
255
308
) -> Optional [AbstractStreamWriter ]:
256
- etag_value = f"{ st .st_mtime_ns :x} -{ st .st_size :x} "
257
- last_modified = st .st_mtime
258
-
259
- # https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1-2
260
- ifmatch = request .if_match
261
- if ifmatch is not None and not self ._etag_match (
262
- etag_value , ifmatch , weak = False
263
- ):
264
- return await self ._precondition_failed (request )
265
-
266
- unmodsince = request .if_unmodified_since
267
- if (
268
- unmodsince is not None
269
- and ifmatch is None
270
- and st .st_mtime > unmodsince .timestamp ()
271
- ):
272
- return await self ._precondition_failed (request )
273
-
274
- # https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2-2
275
- ifnonematch = request .if_none_match
276
- if ifnonematch is not None and self ._etag_match (
277
- etag_value , ifnonematch , weak = True
278
- ):
279
- return await self ._not_modified (request , etag_value , last_modified )
280
-
281
- modsince = request .if_modified_since
282
- if (
283
- modsince is not None
284
- and ifnonematch is None
285
- and st .st_mtime <= modsince .timestamp ()
286
- ):
287
- return await self ._not_modified (request , etag_value , last_modified )
288
-
289
309
status = self ._status
290
310
file_size = st .st_size
291
311
count = file_size
292
-
293
312
start = None
294
313
295
314
ifrange = request .if_range
@@ -378,7 +397,7 @@ async def _prepare_open_file(
378
397
# compress.
379
398
self ._compression = False
380
399
381
- self .etag = etag_value # type: ignore[assignment]
400
+ self .etag = f" { st . st_mtime_ns :x } - { st . st_size :x } " # type: ignore[assignment]
382
401
self .last_modified = st .st_mtime # type: ignore[assignment]
383
402
self .content_length = count
384
403
0 commit comments