Skip to content

Commit

Permalink
Refactor: memoize handler function (#357)
Browse files Browse the repository at this point in the history
* inital

* updated routes

* refactored routes

* refactor http handler

* add test for response class

* Refactor: Path Param Parsing (#358)

* optimized path parameter parsing

* resolve py3.7 comp

* cleanup

* cleanup connection

* Issue-#361: Fix stream 'iterator' typing (#362)

* fix stream 'iterator' typing

* Issue #363: Uploadfile OpenAPI generation raises an exception (#365)

create UploadFile subclass
  • Loading branch information
Goldziher authored Aug 13, 2022
1 parent 1e11006 commit 00b757b
Show file tree
Hide file tree
Showing 26 changed files with 1,028 additions and 681 deletions.
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[flake8]
max-line-length = 120
max-complexity = 12
ignore = E501, B008, W503, C408, B009, B023, C417, PT006, PT007, PT004, PT012
ignore = E501, B008, W503, C408, B009, B023, C417, PT006, PT007, PT004, PT012, SIM401
type-checking-pydantic-enabled = true
type-checking-fastapi-enabled = true
classmethod-decorators =
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/datastructures.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,7 @@
members:
- documentation_only
- value

::: starlite.datastructures.UploadFile
options:
show_source: false
3 changes: 1 addition & 2 deletions docs/usage/4-request-data/3-multipart-form-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ interface for working with files. Therefore, you need to type your file uploads
To access a single file simply type `data` as `UploadFile`:

```python
from starlette.datastructures import UploadFile
from starlite import Body, post, RequestEncodingType
from starlite import Body, UploadFile, post, RequestEncodingType


@post(path="/file-upload")
Expand Down
14 changes: 9 additions & 5 deletions docs/usage/5-responses/8-streaming-responses.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,38 @@
# Streaming Responses

To return a streaming response use the `Stream` class:
To return a streaming response use the `Stream` class. The Stream class receives a single required kwarg - `iterator`:

```python
from typing import AsyncGenerator
from asyncio import sleep
from starlite import get
from starlite.datastructures import Stream
from datetime import datetime
from orjson import dumps


async def my_iterator() -> bytes:
async def my_generator() -> AsyncGenerator[bytes, None]:
while True:
await sleep(0.01)
yield dumps({"current_time": datetime.now()})


@get(path="/time")
def stream_time() -> Stream:
return Stream(iterator=my_iterator)
return Stream(iterator=my_generator())
```

The Stream class receives a single required kwarg - `iterator`, which should be either a sync or an async iterator.
<!-- prettier-ignore -->
!!! note
You can use different kinds of values of the `iterator` keyword - it can be a callable returning a sync or async
generator. The generator itself. A sync or async iterator class, or and instance of this class.

## The Stream Class

`Stream` is a container class used to generate streaming responses and their respective OpenAPI documentation. You can
pass the following kwargs to it:

- `iterator`: An either, either sync or async, that handles streaming data, **required**.
- `iterator`: A sync or async iterator instance, iterator class, generator or callable returning a generator, **required**.
- `background`: A callable wrapped in an instance of `starlite.datastructures.BackgroundTask` or a list
of `BackgroundTask` instances wrapped in `starlite.datastructures.BackgroundTasks`. The callable(s) will be called after
the response is executed. Note - if you return a value from a `before_request` hook, background tasks passed to the
Expand Down
5 changes: 4 additions & 1 deletion starlite/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
State,
Stream,
Template,
UploadFile,
)

from .app import Starlite
Expand Down Expand Up @@ -63,10 +64,11 @@
from .provide import Provide
from .response import Response
from .router import Router
from .routes import BaseRoute, HTTPRoute, WebSocketRoute
from .routes import ASGIRoute, BaseRoute, HTTPRoute, WebSocketRoute
from .types import MiddlewareProtocol, Partial

__all__ = [
"ASGIRoute",
"ASGIRouteHandler",
"AbstractAuthenticationMiddleware",
"AuthenticationResult",
Expand Down Expand Up @@ -119,6 +121,7 @@
"Stream",
"Template",
"TemplateConfig",
"UploadFile",
"ValidationException",
"WebSocket",
"WebSocketRoute",
Expand Down
5 changes: 1 addition & 4 deletions starlite/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,9 @@ def register(self, value: ControllerRouterHandler) -> None: # type: ignore[over
route_handler.resolve_guards()
route_handler.resolve_middleware()
if isinstance(route_handler, HTTPRouteHandler):
route_handler.resolve_response_class()
route_handler.resolve_before_request()
route_handler.resolve_after_request()
route_handler.resolve_after_response()
route_handler.resolve_response_headers()
route_handler.resolve_response_cookies()
route_handler.resolve_response_handler()
if isinstance(route, HTTPRoute):
route.create_handler_map()
elif isinstance(route, WebSocketRoute):
Expand Down
58 changes: 50 additions & 8 deletions starlite/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
from typing import TYPE_CHECKING, Any, Dict, List, Tuple, cast

from starlette.routing import Router as StarletteRouter
from starlette.types import ASGIApp, Receive, Scope, Send

from starlite.enums import ScopeType
from starlite.exceptions import MethodNotAllowedException, NotFoundException
from starlite.parsers import parse_path_params
from starlite.exceptions import (
MethodNotAllowedException,
NotFoundException,
ValidationException,
)

if TYPE_CHECKING:
from typing import Set

from starlette.types import ASGIApp, Receive, Scope, Send

from starlite.app import Starlite
from starlite.routes.base import PathParameterDefinition
from starlite.types import LifeCycleHandler


Expand All @@ -29,7 +34,7 @@ def __init__(
self.app = app
super().__init__(on_startup=on_startup, on_shutdown=on_shutdown)

def _traverse_route_map(self, path: str, scope: Scope) -> Tuple[Dict[str, Any], List[str]]:
def _traverse_route_map(self, path: str, scope: "Scope") -> Tuple[Dict[str, Any], List[str]]:
"""
Traverses the application route mapping and retrieves the correct node for the request url.
Expand Down Expand Up @@ -71,7 +76,38 @@ def _handle_static_path(scope: "Scope", node: Dict[str, Any]) -> None:
start_idx = len(static_path)
scope["path"] = scope["path"][start_idx:] + "/"

def _parse_scope_to_route(self, scope: Scope) -> Tuple[Dict[str, ASGIApp], bool]:
@staticmethod
def _parse_path_parameters(
path_parameter_definitions: List["PathParameterDefinition"], request_path_parameter_values: List[str]
) -> Dict[str, Any]:
"""
Parses path parameters into their expected types
Args:
path_parameter_definitions: A list of [PathParameterDefinition][starlite.route.base.PathParameterDefinition] instances
request_path_parameter_values: A list of raw strings sent as path parameters as part of the request
Raises:
ValidationException
Returns:
A dictionary mapping path parameter names to parsed values
"""
result: Dict[str, Any] = {}

try:
for idx, parameter_definition in enumerate(path_parameter_definitions):
raw_param_value = request_path_parameter_values[idx]
parameter_type = parameter_definition["type"]
parameter_name = parameter_definition["name"]
result[parameter_name] = parameter_type(raw_param_value)
return result
except (ValueError, TypeError, KeyError) as e: # pragma: no cover
raise ValidationException(
f"unable to parse path parameters {','.join(request_path_parameter_values)}"
) from e

def _parse_scope_to_route(self, scope: "Scope") -> Tuple[Dict[str, "ASGIApp"], bool]:
"""
Given a scope object, retrieve the _asgi_handlers and _is_asgi values from correct trie node.
"""
Expand All @@ -84,15 +120,21 @@ def _parse_scope_to_route(self, scope: Scope) -> Tuple[Dict[str, ASGIApp], bool]
path_params: List[str] = []
else:
current_node, path_params = self._traverse_route_map(path=path, scope=scope)

scope["path_params"] = (
parse_path_params(current_node["_path_parameters"], path_params) if current_node["_path_parameters"] else {}
self._parse_path_parameters(
path_parameter_definitions=current_node["_path_parameters"], request_path_parameter_values=path_params
)
if path_params
else {}
)

asgi_handlers = cast("Dict[str, ASGIApp]", current_node["_asgi_handlers"])
is_asgi = cast("bool", current_node["_is_asgi"])
return asgi_handlers, is_asgi

@staticmethod
def _resolve_asgi_app(scope: Scope, asgi_handlers: Dict[str, ASGIApp], is_asgi: bool) -> ASGIApp:
def _resolve_asgi_app(scope: "Scope", asgi_handlers: Dict[str, "ASGIApp"], is_asgi: bool) -> "ASGIApp":
"""
Given a scope, retrieves the correct ASGI App for the route
"""
Expand All @@ -104,7 +146,7 @@ def _resolve_asgi_app(scope: Scope, asgi_handlers: Dict[str, ASGIApp], is_asgi:
return asgi_handlers[scope["method"]]
return asgi_handlers[ScopeType.WEBSOCKET]

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
"""
The main entry point to the Router class.
"""
Expand Down
18 changes: 13 additions & 5 deletions starlite/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

from orjson import OPT_OMIT_MICROSECONDS, OPT_SERIALIZE_NUMPY, dumps, loads
from starlette.requests import Request as StarletteRequest
from starlette.requests import empty_receive, empty_send
from starlette.websockets import WebSocket as StarletteWebSocket
from starlette.websockets import WebSocketState

from starlite.exceptions import ImproperlyConfiguredException, InternalServerException
from starlite.parsers import parse_query_params
from starlite.types import Empty

if TYPE_CHECKING:
from starlette.types import Receive, Scope, Send
from typing_extensions import Literal

from starlite.app import Starlite
Expand All @@ -23,6 +26,10 @@ class Request(StarletteRequest, Generic[User, Auth]):
The Starlite Request class
"""

def __init__(self, scope: "Scope", receive: "Receive" = empty_receive, send: "Send" = empty_send):
super().__init__(scope, receive, send)
self._json: Any = Empty

@property
def app(self) -> "Starlite":
"""
Expand Down Expand Up @@ -87,11 +94,12 @@ async def json(self) -> Any:
Returns:
An arbitrary value
"""
if not hasattr(self, "_json"):
body = self.scope.get("_body")
if not body:
body = self.scope["_body"] = await self.body()
self._json = loads(body or "null") # pylint: disable=attribute-defined-outside-init
if self._json is Empty:
if "_body" not in self.scope:
body = self.scope["_body"] = (await self.body()) or b"null"
else:
body = self.scope["_body"]
self._json = loads(body)
return self._json


Expand Down
52 changes: 48 additions & 4 deletions starlite/datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
AsyncIterable,
AsyncIterator,
Callable,
Dict,
Generator,
Generic,
Iterable,
Iterator,
List,
Optional,
Type,
TypeVar,
Union,
cast,
Expand All @@ -23,14 +28,19 @@
from starlette.background import BackgroundTask as StarletteBackgroundTask
from starlette.background import BackgroundTasks as StarletteBackgroundTasks
from starlette.datastructures import State as StarletteStateClass
from starlette.datastructures import UploadFile as StarletteUploadFile
from starlette.responses import FileResponse, RedirectResponse
from starlette.responses import Response as StarletteResponse
from starlette.responses import StreamingResponse
from typing_extensions import Literal, ParamSpec

from starlite.openapi.enums import OpenAPIType

P = ParamSpec("P")

if TYPE_CHECKING:
from pydantic.fields import ModelField

from starlite.app import Starlite
from starlite.enums import MediaType
from starlite.response import TemplateResponse
Expand Down Expand Up @@ -230,8 +240,17 @@ class Stream(ResponseContainer[StreamingResponse]):
Container type for returning Stream responses
"""

iterator: Union[Iterator[Any], AsyncIterator[Any]]
"""Iterator returning stream chunks"""
iterator: Union[
Iterator[Union[str, bytes]],
Generator[Union[str, bytes], Any, Any],
AsyncIterator[Union[str, bytes]],
AsyncGenerator[Union[str, bytes], Any],
Type[Iterator[Union[str, bytes]]],
Type[AsyncIterator[Union[str, bytes]]],
Callable[[], AsyncGenerator[Union[str, bytes], Any]],
Callable[[], Generator[Union[str, bytes], Any, Any]],
]
"""Iterator, Generator or async Iterator or Generator returning stream chunks"""

def to_response(
self, headers: Dict[str, Any], media_type: Union["MediaType", str], status_code: int, app: "Starlite"
Expand All @@ -248,9 +267,10 @@ def to_response(
Returns:
A StreamingResponse instance
"""

return StreamingResponse(
background=self.background,
content=self.iterator,
content=self.iterator if isinstance(self.iterator, (Iterable, AsyncIterable)) else self.iterator(),
headers=headers,
media_type=media_type,
status_code=status_code,
Expand Down Expand Up @@ -286,7 +306,7 @@ def to_response(
Returns:
A TemplateResponse instance
"""
from starlite import ImproperlyConfiguredException
from starlite.exceptions import ImproperlyConfiguredException
from starlite.response import TemplateResponse

if not app.template_engine:
Expand Down Expand Up @@ -320,3 +340,27 @@ def validate_value(cls, value: Any, values: Dict[str, Any]) -> Any: # pylint: d
if values.get("documentation_only") or value is not None:
return value
raise ValueError("value must be set if documentation_only is false")


class UploadFile(StarletteUploadFile):
@classmethod
def __modify_schema__(cls, field_schema: Dict[str, Any], field: Optional["ModelField"]) -> None:
"""
Creates a pydantic JSON schema
Args:
field_schema: The schema being generated for the field
field: the model class field
Returns:
None
"""
if field:
field_schema.update(
{
"type": OpenAPIType.OBJECT,
"properties": {
"filename": {"type": OpenAPIType.STRING, "contentMediaType": "application/octet-stream"}
},
}
)
Loading

0 comments on commit 00b757b

Please sign in to comment.