from __future__ import annotations from datetime import datetime, timezone from email.utils import formatdate, parsedate_to_datetime from mimetypes import guess_type from os import path from pathlib import PurePath from time import time from typing import Any, AnyStr, Callable from urllib.parse import quote_plus from sanic.compat import Header, open_async, stat_async from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE from sanic.helpers import Default, _default from sanic.log import logger from sanic.models.protocol_types import HTMLProtocol, Range from .types import HTTPResponse, JSONResponse, ResponseStream def empty( status: int = 204, headers: dict[str, str] | None = None ) -> HTTPResponse: """Returns an empty response to the client. Args: status (int, optional): HTTP response code. Defaults to `204`. headers ([type], optional): Custom HTTP headers. Defaults to `None`. Returns: HTTPResponse: An empty response to the client. """ return HTTPResponse(body=b"", status=status, headers=headers) def json( body: Any, status: int = 200, headers: dict[str, str] | None = None, content_type: str = "application/json", dumps: Callable[..., AnyStr] | None = None, **kwargs: Any, ) -> JSONResponse: """Returns response object with body in json format. Args: body (Any): Response data to be serialized. status (int, optional): HTTP response code. Defaults to `200`. headers (Dict[str, str], optional): Custom HTTP headers. Defaults to `None`. content_type (str, optional): The content type (string) of the response. Defaults to `"application/json"`. dumps (Callable[..., AnyStr], optional): A custom json dumps function. Defaults to `None`. **kwargs (Any): Remaining arguments that are passed to the json encoder. Returns: JSONResponse: A response object with body in json format. """ # noqa: E501 return JSONResponse( body, status=status, headers=headers, content_type=content_type, dumps=dumps, **kwargs, ) def text( body: str, status: int = 200, headers: dict[str, str] | None = None, content_type: str = "text/plain; charset=utf-8", ) -> HTTPResponse: """Returns response object with body in text format. Args: body (str): Response data. status (int, optional): HTTP response code. Defaults to `200`. headers (Dict[str, str], optional): Custom HTTP headers. Defaults to `None`. content_type (str, optional): The content type (string) of the response. Defaults to `"text/plain; charset=utf-8"`. Returns: HTTPResponse: A response object with body in text format. Raises: TypeError: If the body is not a string. """ # noqa: E501 if not isinstance(body, str): raise TypeError( f"Bad body type. Expected str, got {type(body).__name__})" ) return HTTPResponse( body, status=status, headers=headers, content_type=content_type ) def raw( body: AnyStr | None, status: int = 200, headers: dict[str, str] | None = None, content_type: str = DEFAULT_HTTP_CONTENT_TYPE, ) -> HTTPResponse: """Returns response object without encoding the body. Args: body (Optional[AnyStr]): Response data. status (int, optional): HTTP response code. Defaults to `200`. headers (Dict[str, str], optional): Custom HTTP headers. Defaults to `None`. content_type (str, optional): The content type (string) of the response. Defaults to `"application/octet-stream"`. Returns: HTTPResponse: A response object without encoding the body. """ # noqa: E501 return HTTPResponse( body=body, status=status, headers=headers, content_type=content_type, ) def html( body: str | bytes | HTMLProtocol, status: int = 200, headers: dict[str, str] | None = None, ) -> HTTPResponse: """Returns response object with body in html format. Body should be a `str` or `bytes` like object, or an object with `__html__` or `_repr_html_`. Args: body (Union[str, bytes, HTMLProtocol]): Response data. status (int, optional): HTTP response code. Defaults to `200`. headers (Dict[str, str], optional): Custom HTTP headers. Defaults to `None`. Returns: HTTPResponse: A response object with body in html format. """ # noqa: E501 if not isinstance(body, (str, bytes)): if hasattr(body, "__html__"): body = body.__html__() elif hasattr(body, "_repr_html_"): body = body._repr_html_() return HTTPResponse( body, status=status, headers=headers, content_type="text/html; charset=utf-8", ) async def validate_file( request_headers: Header, last_modified: datetime | float | int ) -> HTTPResponse | None: """Validate file based on request headers. Args: request_headers (Header): The request headers. last_modified (Union[datetime, float, int]): The last modified date and time of the file. Returns: Optional[HTTPResponse]: A response object with status 304 if the file is not modified. """ # noqa: E501 try: if_modified_since = request_headers.getone("If-Modified-Since") except KeyError: return None try: if_modified_since = parsedate_to_datetime(if_modified_since) except (TypeError, ValueError): logger.warning( "Ignorning invalid If-Modified-Since header received: '%s'", if_modified_since, ) return None if not isinstance(last_modified, datetime): last_modified = datetime.fromtimestamp( float(last_modified), tz=timezone.utc ).replace(microsecond=0) if ( last_modified.utcoffset() is None and if_modified_since.utcoffset() is not None ): logger.warning( "Cannot compare tz-aware and tz-naive datetimes. To avoid " "this conflict Sanic is converting last_modified to UTC." ) last_modified.replace(tzinfo=timezone.utc) elif ( last_modified.utcoffset() is not None and if_modified_since.utcoffset() is None ): logger.warning( "Cannot compare tz-aware and tz-naive datetimes. To avoid " "this conflict Sanic is converting if_modified_since to UTC." ) if_modified_since.replace(tzinfo=timezone.utc) if last_modified.timestamp() <= if_modified_since.timestamp(): return HTTPResponse(status=304) return None async def file( location: str | PurePath, status: int = 200, request_headers: Header | None = None, validate_when_requested: bool = True, mime_type: str | None = None, headers: dict[str, str] | None = None, filename: str | None = None, last_modified: datetime | float | int | Default | None = _default, max_age: float | int | None = None, no_store: bool | None = None, _range: Range | None = None, ) -> HTTPResponse: """Return a response object with file data. Args: location (Union[str, PurePath]): Location of file on system. status (int, optional): HTTP response code. Won't enforce the passed in status if only a part of the content will be sent (206) or file is being validated (304). Defaults to 200. request_headers (Optional[Header], optional): The request headers. validate_when_requested (bool, optional): If `True`, will validate the file when requested. Defaults to True. mime_type (Optional[str], optional): Specific mime_type. headers (Optional[Dict[str, str]], optional): Custom Headers. filename (Optional[str], optional): Override filename. last_modified (Optional[Union[datetime, float, int, Default]], optional): The last modified date and time of the file. max_age (Optional[Union[float, int]], optional): Max age for cache control. no_store (Optional[bool], optional): Any cache should not store this response. Defaults to None. _range (Optional[Range], optional): Returns: HTTPResponse: The response object with the file data. """ # noqa: E501 if isinstance(last_modified, datetime): last_modified = last_modified.replace(microsecond=0).timestamp() elif isinstance(last_modified, Default): stat = await stat_async(location) last_modified = stat.st_mtime if ( validate_when_requested and request_headers is not None and last_modified ): response = await validate_file(request_headers, last_modified) if response: return response headers = headers or {} if last_modified: headers.setdefault( "Last-Modified", formatdate(last_modified, usegmt=True) ) if filename: headers.setdefault( "Content-Disposition", f'attachment; filename="{filename}"' ) if no_store: cache_control = "no-store" elif max_age: cache_control = f"public, max-age={max_age}" headers.setdefault( "expires", formatdate( time() + max_age, usegmt=True, ), ) else: cache_control = "no-cache" headers.setdefault("cache-control", cache_control) filename = filename or path.split(location)[-1] async with await open_async(location, mode="rb") as f: if _range: await f.seek(_range.start) out_stream = await f.read(_range.size) headers["Content-Range"] = ( f"bytes {_range.start}-{_range.end}/{_range.total}" ) status = 206 else: out_stream = await f.read() content_type = mime_type or guess_content_type( filename, fallback="text/plain; charset=utf-8" ) return HTTPResponse( body=out_stream, status=status, headers=headers, content_type=content_type, ) def redirect( to: str, headers: dict[str, str] | None = None, status: int = 302, content_type: str = "text/html; charset=utf-8", ) -> HTTPResponse: """Cause a HTTP redirect (302 by default) by setting a Location header. Args: to (str): path or fully qualified URL to redirect to headers (Optional[Dict[str, str]], optional): optional dict of headers to include in the new request. Defaults to None. status (int, optional): status code (int) of the new request, defaults to 302. Defaults to 302. content_type (str, optional): the content type (string) of the response. Defaults to "text/html; charset=utf-8". Returns: HTTPResponse: A response object with the redirect. """ # noqa: E501 headers = headers or {} # URL Quote the URL before redirecting safe_to = quote_plus(to, safe=":/%#?&=@[]!$&'()*+,;") # According to RFC 7231, a relative URI is now permitted. headers["Location"] = safe_to return HTTPResponse( status=status, headers=headers, content_type=content_type ) async def file_stream( location: str | PurePath, status: int = 200, chunk_size: int = 4096, mime_type: str | None = None, headers: dict[str, str] | None = None, filename: str | None = None, _range: Range | None = None, ) -> ResponseStream: """Return a streaming response object with file data. :param location: Location of file on system. :param chunk_size: The size of each chunk in the stream (in bytes) :param mime_type: Specific mime_type. :param headers: Custom Headers. :param filename: Override filename. :param _range: Args: location (Union[str, PurePath]): Location of file on system. status (int, optional): HTTP response code. Won't enforce the passed in status if only a part of the content will be sent (206) or file is being validated (304). Defaults to `200`. chunk_size (int, optional): The size of each chunk in the stream (in bytes). Defaults to `4096`. mime_type (Optional[str], optional): Specific mime_type. headers (Optional[Dict[str, str]], optional): Custom HTTP headers. filename (Optional[str], optional): Override filename. _range (Optional[Range], optional): The range of bytes to send. """ # noqa: E501 headers = headers or {} if filename: headers.setdefault( "Content-Disposition", f'attachment; filename="{filename}"' ) filename = filename or path.split(location)[-1] mime_type = mime_type or guess_type(filename)[0] or "text/plain" if _range: start = _range.start end = _range.end total = _range.total headers["Content-Range"] = f"bytes {start}-{end}/{total}" status = 206 async def _streaming_fn(response): async with await open_async(location, mode="rb") as f: if _range: await f.seek(_range.start) to_send = _range.size while to_send > 0: content = await f.read(min((_range.size, chunk_size))) if len(content) < 1: break to_send -= len(content) await response.write(content) else: while True: content = await f.read(chunk_size) if len(content) < 1: break await response.write(content) return ResponseStream( streaming_fn=_streaming_fn, status=status, headers=headers, content_type=mime_type, ) def guess_content_type( file_path: str | PurePath, fallback: str = DEFAULT_HTTP_CONTENT_TYPE, ) -> str: """Guess the content type (rather than MIME only) by the file extension.""" mediatype = guess_type(file_path)[0] if mediatype is None: return fallback if mediatype.startswith("text/"): return f"{mediatype}; charset=utf-8" return mediatype