aio-libs / aiohttp
1 10
import asyncio
2 10
import mimetypes
3 10
import os
4 10
import pathlib
5 10
import sys
6 10
from typing import (  # noqa
7
    IO,
8
    TYPE_CHECKING,
9
    Any,
10
    Awaitable,
11
    Callable,
12
    Iterator,
13
    List,
14
    Optional,
15
    Tuple,
16
    Union,
17
    cast,
18
)
19

20 10
from typing_extensions import Final
21

22 10
from . import hdrs
23 10
from .abc import AbstractStreamWriter
24 10
from .helpers import ETAG_ANY, ETag
25 10
from .typedefs import LooseHeaders
26 10
from .web_exceptions import (
27
    HTTPNotModified,
28
    HTTPPartialContent,
29
    HTTPPreconditionFailed,
30
    HTTPRequestRangeNotSatisfiable,
31
)
32 10
from .web_response import StreamResponse
33

34 10
__all__ = ("FileResponse",)
35

36
if TYPE_CHECKING:  # pragma: no cover
37
    from .web_request import BaseRequest
38

39

40 10
_T_OnChunkSent = Optional[Callable[[bytes], Awaitable[None]]]
41

42

43 10
NOSENDFILE: Final[bool] = bool(os.environ.get("AIOHTTP_NOSENDFILE"))
44

45

46 10
class FileResponse(StreamResponse):
47
    """A response object can be used to send files."""
48

49 10
    def __init__(
50
        self,
51
        path: Union[str, pathlib.Path],
52
        chunk_size: int = 256 * 1024,
53
        status: int = 200,
54
        reason: Optional[str] = None,
55
        headers: Optional[LooseHeaders] = None,
56
    ) -> None:
57 10
        super().__init__(status=status, reason=reason, headers=headers)
58

59 10
        if isinstance(path, str):
60 0
            path = pathlib.Path(path)
61

62 10
        self._path = path
63 10
        self._chunk_size = chunk_size
64

65 10
    async def _sendfile_fallback(
66
        self, writer: AbstractStreamWriter, fobj: IO[Any], offset: int, count: int
67
    ) -> AbstractStreamWriter:
68
        # To keep memory usage low,fobj is transferred in chunks
69
        # controlled by the constructor's chunk_size argument.
70

71 10
        chunk_size = self._chunk_size
72 10
        loop = asyncio.get_event_loop()
73

74 10
        await loop.run_in_executor(None, fobj.seek, offset)
75

76 10
        chunk = await loop.run_in_executor(None, fobj.read, chunk_size)
77 10
        while chunk:
78 10
            await writer.write(chunk)
79 10
            count = count - chunk_size
80 10
            if count <= 0:
81 10
                break
82 10
            chunk = await loop.run_in_executor(None, fobj.read, min(chunk_size, count))
83

84 10
        await writer.drain()
85 10
        return writer
86

87 10
    async def _sendfile(
88
        self, request: "BaseRequest", fobj: IO[Any], offset: int, count: int
89
    ) -> AbstractStreamWriter:
90 10
        writer = await super().prepare(request)
91 10
        assert writer is not None
92

93 10
        if NOSENDFILE or sys.version_info < (3, 7) or self.compression:
94 10
            return await self._sendfile_fallback(writer, fobj, offset, count)
95

96 10
        loop = request._loop
97 10
        transport = request.transport
98 10
        assert transport is not None
99

100 10
        try:
101 10
            await loop.sendfile(transport, fobj, offset, count)
102 10
        except NotImplementedError:
103 10
            return await self._sendfile_fallback(writer, fobj, offset, count)
104

105 10
        await super().write_eof()
106 10
        return writer
107

108 10
    @staticmethod
109 10
    def _strong_etag_match(etag_value: str, etags: Tuple[ETag, ...]) -> bool:
110 10
        if len(etags) == 1 and etags[0].value == ETAG_ANY:
111 10
            return True
112 10
        return any(etag.value == etag_value for etag in etags if not etag.is_weak)
113

114 10
    async def _not_modified(
115
        self, request: "BaseRequest", etag_value: str, last_modified: float
116
    ) -> Optional[AbstractStreamWriter]:
117 10
        self.set_status(HTTPNotModified.status_code)
118 10
        self._length_check = False
119 10
        self.etag = etag_value  # type: ignore[assignment]
120 10
        self.last_modified = last_modified  # type: ignore[assignment]
121
        # Delete any Content-Length headers provided by user. HTTP 304
122
        # should always have empty response body
123 10
        return await super().prepare(request)
124

125 10
    async def _precondition_failed(
126
        self, request: "BaseRequest"
127
    ) -> Optional[AbstractStreamWriter]:
128 10
        self.set_status(HTTPPreconditionFailed.status_code)
129 10
        self.content_length = 0
130 10
        return await super().prepare(request)
131

132 10
    async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
133 10
        filepath = self._path
134

135 10
        gzip = False
136 10
        if "gzip" in request.headers.get(hdrs.ACCEPT_ENCODING, ""):
137 10
            gzip_path = filepath.with_name(filepath.name + ".gz")
138

139 10
            if gzip_path.is_file():
140 10
                filepath = gzip_path
141 10
                gzip = True
142

143 10
        loop = asyncio.get_event_loop()
144 10
        st: os.stat_result = await loop.run_in_executor(None, filepath.stat)
145

146 10
        etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
147 10
        last_modified = st.st_mtime
148

149
        # https://tools.ietf.org/html/rfc7232#section-6
150 10
        ifmatch = request.if_match
151 10
        if ifmatch is not None and not self._strong_etag_match(etag_value, ifmatch):
152 10
            return await self._precondition_failed(request)
153

154 10
        unmodsince = request.if_unmodified_since
155 10
        if (
156
            unmodsince is not None
157
            and ifmatch is None
158
            and st.st_mtime > unmodsince.timestamp()
159
        ):
160 10
            return await self._precondition_failed(request)
161

162 10
        ifnonematch = request.if_none_match
163 10
        if ifnonematch is not None and self._strong_etag_match(etag_value, ifnonematch):
164 10
            return await self._not_modified(request, etag_value, last_modified)
165

166 10
        modsince = request.if_modified_since
167 10
        if (
168
            modsince is not None
169
            and ifnonematch is None
170
            and st.st_mtime <= modsince.timestamp()
171
        ):
172 10
            return await self._not_modified(request, etag_value, last_modified)
173

174 10
        if hdrs.CONTENT_TYPE not in self.headers:
175 10
            ct, encoding = mimetypes.guess_type(str(filepath))
176 10
            if not ct:
177 10
                ct = "application/octet-stream"
178 10
            should_set_ct = True
179
        else:
180 10
            encoding = "gzip" if gzip else None
181 10
            should_set_ct = False
182

183 10
        status = self._status
184 10
        file_size = st.st_size
185 10
        count = file_size
186

187 10
        start = None
188

189 10
        ifrange = request.if_range
190 10
        if ifrange is None or st.st_mtime <= ifrange.timestamp():
191
            # If-Range header check:
192
            # condition = cached date >= last modification date
193
            # return 206 if True else 200.
194
            # if False:
195
            #   Range header would not be processed, return 200
196
            # if True but Range header missing
197
            #   return 200
198 10
            try:
199 10
                rng = request.http_range
200 10
                start = rng.start
201 10
                end = rng.stop
202 10
            except ValueError:
203
                # https://tools.ietf.org/html/rfc7233:
204
                # A server generating a 416 (Range Not Satisfiable) response to
205
                # a byte-range request SHOULD send a Content-Range header field
206
                # with an unsatisfied-range value.
207
                # The complete-length in a 416 response indicates the current
208
                # length of the selected representation.
209
                #
210
                # Will do the same below. Many servers ignore this and do not
211
                # send a Content-Range header with HTTP 416
212 10
                self.headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
213 10
                self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
214 10
                return await super().prepare(request)
215

216
            # If a range request has been made, convert start, end slice
217
            # notation into file pointer offset and count
218 10
            if start is not None or end is not None:
219 10
                if start < 0 and end is None:  # return tail of file
220 10
                    start += file_size
221 10
                    if start < 0:
222
                        # if Range:bytes=-1000 in request header but file size
223
                        # is only 200, there would be trouble without this
224 10
                        start = 0
225 10
                    count = file_size - start
226
                else:
227
                    # rfc7233:If the last-byte-pos value is
228
                    # absent, or if the value is greater than or equal to
229
                    # the current length of the representation data,
230
                    # the byte range is interpreted as the remainder
231
                    # of the representation (i.e., the server replaces the
232
                    # value of last-byte-pos with a value that is one less than
233
                    # the current length of the selected representation).
234 10
                    count = (
235
                        min(end if end is not None else file_size, file_size) - start
236
                    )
237

238 10
                if start >= file_size:
239
                    # HTTP 416 should be returned in this case.
240
                    #
241
                    # According to https://tools.ietf.org/html/rfc7233:
242
                    # If a valid byte-range-set includes at least one
243
                    # byte-range-spec with a first-byte-pos that is less than
244
                    # the current length of the representation, or at least one
245
                    # suffix-byte-range-spec with a non-zero suffix-length,
246
                    # then the byte-range-set is satisfiable. Otherwise, the
247
                    # byte-range-set is unsatisfiable.
248 10
                    self.headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
249 10
                    self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
250 10
                    return await super().prepare(request)
251

252 10
                status = HTTPPartialContent.status_code
253
                # Even though you are sending the whole file, you should still
254
                # return a HTTP 206 for a Range request.
255 10
                self.set_status(status)
256

257 10
        if should_set_ct:
258 10
            self.content_type = ct  # type: ignore[assignment]
259 10
        if encoding:
260 10
            self.headers[hdrs.CONTENT_ENCODING] = encoding
261 10
        if gzip:
262 10
            self.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
263

264 10
        self.etag = etag_value  # type: ignore[assignment]
265 10
        self.last_modified = st.st_mtime  # type: ignore[assignment]
266 10
        self.content_length = count
267

268 10
        self.headers[hdrs.ACCEPT_RANGES] = "bytes"
269

270 10
        real_start = cast(int, start)
271

272 10
        if status == HTTPPartialContent.status_code:
273 10
            self.headers[hdrs.CONTENT_RANGE] = "bytes {}-{}/{}".format(
274
                real_start, real_start + count - 1, file_size
275
            )
276

277 10
        if request.method == hdrs.METH_HEAD or self.status in [204, 304]:
278 10
            return await super().prepare(request)
279

280 10
        fobj = await loop.run_in_executor(None, filepath.open, "rb")
281 10
        if start:  # be aware that start could be None or int=0 here.
282 10
            offset = start
283
        else:
284 10
            offset = 0
285

286 10
        try:
287 10
            return await self._sendfile(request, fobj, offset, count)
288
        finally:
289 10
            await loop.run_in_executor(None, fobj.close)

Read our documentation on viewing source code .

Loading