Skip to content
This repository has been archived by the owner on Apr 29, 2024. It is now read-only.

Commit

Permalink
chore: initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Bogdanp committed Jun 10, 2018
0 parents commit 02c76ae
Show file tree
Hide file tree
Showing 39 changed files with 3,299 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__
.coverage
.mypy_cache
.pytest_cache
htmlcov
molten.egg-info
setup.py
Empty file added README.md
Empty file.
63 changes: 63 additions & 0 deletions molten/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from .app import App, BaseApp
from .dependency_injection import Component, DependencyInjector, DependencyResolver
from .errors import DIError, HeaderMissing, HTTPError, MoltenError, ParamMissing, RequestParserNotAvailable
from .http import Headers, QueryParams, Request, Response
from .http.status_codes import *
from .middleware import ResponseRendererMiddleware
from .parsers import JSONParser, RequestParser, URLEncodingParser
from .renderers import JSONRenderer, ResponseRenderer
from .router import Include, Route, Router
from .testing import TestClient, to_environ
from .typing import Header, Host, Method, Port, QueryParam, QueryString, RequestBody, RequestData, RequestInput, Scheme

__version__ = "0.1.0"

__all__ = [
"BaseApp", "App",

# Router
"Router", "Route", "Include",

# HTTP
"Method", "Scheme", "Host", "Port", "QueryString", "QueryParams", "QueryParam",
"Headers", "Header", "RequestInput", "RequestBody", "RequestData",
"Request", "Response",

# Dependency-injection
"DependencyInjector", "DependencyResolver", "Component",

# Parsers
"RequestParser", "JSONParser", "URLEncodingParser",

# Renderers
"ResponseRenderer", "JSONRenderer",

# Middleware
"ResponseRendererMiddleware",

# Errors
"MoltenError", "DIError", "HTTPError", "HeaderMissing", "ParamMissing", "RequestParserNotAvailable",

# Testing
"TestClient", "to_environ",

# Status codes
# 1xx
"HTTP_100", "HTTP_101", "HTTP_102",

# 2xx
"HTTP_200", "HTTP_201", "HTTP_202", "HTTP_203", "HTTP_204", "HTTP_205", "HTTP_206", "HTTP_207", "HTTP_208",

# 3xx
"HTTP_300", "HTTP_301", "HTTP_302", "HTTP_303", "HTTP_304", "HTTP_305", "HTTP_307", "HTTP_308",

# 4xx
"HTTP_400", "HTTP_401", "HTTP_402", "HTTP_403", "HTTP_404", "HTTP_405", "HTTP_406", "HTTP_407", "HTTP_408",
"HTTP_409", "HTTP_410", "HTTP_411", "HTTP_412", "HTTP_413", "HTTP_414", "HTTP_415", "HTTP_416", "HTTP_417",
"HTTP_418", "HTTP_421", "HTTP_422", "HTTP_423", "HTTP_424", "HTTP_426", "HTTP_428", "HTTP_429", "HTTP_431",
"HTTP_444", "HTTP_451", "HTTP_499",

# 5xx
"HTTP_500", "HTTP_501", "HTTP_502", "HTTP_503", "HTTP_504", "HTTP_505", "HTTP_506", "HTTP_507", "HTTP_508",
"HTTP_510", "HTTP_511", "HTTP_599",
]
124 changes: 124 additions & 0 deletions molten/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import logging
import sys
from functools import partial
from typing import Any, Callable, Iterable, List, Optional
from wsgiref.util import FileWrapper # type: ignore

from .components import HeaderComponent, QueryParamComponent, RequestBodyComponent, RequestDataComponent
from .dependency_injection import Component, DependencyInjector
from .errors import RequestParserNotAvailable
from .http import HTTP_204, HTTP_404, HTTP_415, HTTP_500, Headers, QueryParams, Request, Response
from .middleware import ResponseRendererMiddleware
from .parsers import JSONParser, RequestParser, URLEncodingParser
from .renderers import JSONRenderer, ResponseRenderer
from .router import RouteLike, Router
from .typing import Environ, Host, Method, Port, QueryString, RequestInput, Scheme, StartResponse

LOGGER = logging.getLogger(__name__)

#: The type of middleware functions.
Middleware = Callable[[Callable[..., Any]], Callable[..., Any]]


class BaseApp:
"""Base class for App implementations.
"""

def __init__(
self,
routes: Optional[List[RouteLike]] = None,
middleware: Optional[List[Middleware]] = None,
components: Optional[List[Component]] = None,
parsers: Optional[List[RequestParser]] = None,
renderers: Optional[List[ResponseRenderer]] = None,
) -> None:
self.router = Router(routes)
self.add_route = self.router.add_route
self.add_routes = self.router.add_routes

self.parsers = parsers or [
JSONParser(),
URLEncodingParser(),
]
self.renderers = renderers or [JSONRenderer()]
self.middleware = middleware or [
ResponseRendererMiddleware(self.renderers)
]
self.components = (components or []) + [
HeaderComponent(),
QueryParamComponent(),
RequestBodyComponent(),
RequestDataComponent(self.parsers)
]
self.injector = DependencyInjector(self.components)

def handle_404(self) -> Response:
"""Called whenever a route cannot be found.
"""
return Response(HTTP_404, content="Not Found")

def handle_415(self) -> Response:
"""Called whenever a request comes in with an unsupported
content type.
"""
return Response(HTTP_415, content="Unsupported Media Type")

def handle_exception(self, exception: BaseException) -> Response:
"""Called whenever an unhandled exception occurs in middleware
or a handler. Dependencies are injected into this just like a
normal handler.
"""
LOGGER.exception("An unhandled exception occurred.")
return Response(HTTP_500, content="Internal Server Error")

def __call__(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]: # pragma: no cover
raise NotImplementedError("apps must implement '__call__'")


class App(BaseApp):
"""An application that implements the WSGI interface.
"""

def __call__(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]:
request = Request.from_environ(environ)
resolver = self.injector.get_resolver({
Request: request,
Method: Method(request.method),
Scheme: Scheme(request.scheme),
Host: Host(request.host),
Port: Port(request.port),
QueryString: QueryString(environ["QUERY_STRING"]),
QueryParams: request.params,
Headers: request.headers,
RequestInput: RequestInput(request.body_file),
})

try:
route_and_params = self.router.match(request.method, request.path)
if route_and_params is not None:
route, params = route_and_params
handler: Callable[..., Any] = partial(route.handler, **params)
else:
params = {}
handler = self.handle_404

handler = resolver.resolve(handler, params)
for middleware in reversed(self.middleware):
handler = resolver.resolve(middleware(handler))

exc_info = None
response = handler()
except RequestParserNotAvailable:
exc_info = None
response = resolver.resolve(self.handle_415)()
except Exception as e:
exc_info = sys.exc_info()
response = resolver.resolve(self.handle_exception, {"exception": e})()

response.headers.add("content-length", str(response.content_length))
start_response(response.status, list(response.headers), exc_info)
if response.status != HTTP_204:
wrapper = environ.get("wsgi.file_wrapper", FileWrapper)
return wrapper(response.stream)
else:
return []
72 changes: 72 additions & 0 deletions molten/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from collections import defaultdict
from typing import Dict, Iterable, Iterator, List, Optional, Tuple, Union

Mapping = Union[Dict[str, Union[str, List[str]]], List[Tuple[str, str]]]


class MultiDict(Iterable[Tuple[str, str]]):
"""A mapping from param names to lists of values. Once
constructed, these instances cannot be modified.
"""

__slots__ = ["_data"]

def __init__(self, mapping: Optional[Mapping] = None) -> None:
self._data: Dict[str, List[str]] = defaultdict(list)
self._add_all(mapping or {})

def _add(self, name: str, value: Union[str, List[str]]) -> None:
"""Add values for a particular key.
"""
if isinstance(value, list):
self._data[name].extend(value)
else:
self._data[name].append(value)

def _add_all(self, mapping: Mapping) -> None:
"""Add a group of values.
"""
items: Iterable[Tuple[str, Union[str, List[str]]]]

if isinstance(mapping, dict):
items = mapping.items()
else:
items = mapping

for name, value_or_values in items:
self._add(name, value_or_values)

def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
"""Get the last value for a given key.
"""
try:
return self[name]
except KeyError:
return default

def get_all(self, name: str) -> List[str]:
"""Get all the values for a given key.
"""
return self._data[name]

def __getitem__(self, name: str) -> str:
"""Get the last value for a given key.
Raises:
KeyError: When the key is missing.
"""
try:
return self._data[name][-1]
except IndexError:
raise KeyError(name)

def __iter__(self) -> Iterator[Tuple[str, str]]:
"""Iterate over all the parameters.
"""
for name, values in self._data.items():
for value in values:
yield name, value

def __repr__(self) -> str:
mapping = ", ".join(f"{repr(name)}: {repr(value)}" for name, value in self._data.items())
return f"{type(self).__name__}({{{mapping}}})"
92 changes: 92 additions & 0 deletions molten/components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from inspect import Parameter
from typing import List, Optional, TypeVar

from .dependency_injection import DependencyResolver
from .errors import HeaderMissing, HTTPError, ParamMissing, RequestParserNotAvailable
from .http import HTTP_400, Headers, QueryParams
from .parsers import RequestParser
from .typing import Header, QueryParam, RequestBody, RequestData, RequestInput, extract_optional_annotation

_T = TypeVar("_T")


class HeaderComponent:
"""Retrieves a named header from the request.
"""

is_cacheable = False
is_singleton = False

def can_handle_parameter(self, parameter: Parameter) -> bool:
_, annotation = extract_optional_annotation(parameter.annotation)
return annotation is Header

def resolve(self, parameter: Parameter, headers: Headers) -> Optional[str]:
is_optional, _ = extract_optional_annotation(parameter.annotation)
header_name = parameter.name.replace("_", "-")

try:
return headers[header_name]
except HeaderMissing:
if is_optional:
return None

raise HTTPError(HTTP_400, {header_name: "missing"})


class QueryParamComponent:
"""Retrieves a named query param from the request.
"""

is_cacheable = False
is_singleton = False

def can_handle_parameter(self, parameter: Parameter) -> bool:
_, annotation = extract_optional_annotation(parameter.annotation)
return annotation is QueryParam

def resolve(self, parameter: Parameter, params: QueryParams) -> Optional[str]:
is_optional, _ = extract_optional_annotation(parameter.annotation)

try:
return params[parameter.name]
except ParamMissing:
if is_optional:
return None

raise HTTPError(HTTP_400, {parameter.name: "missing"})


class RequestBodyComponent:
"""A component that reads the entire request body into a string.
"""

is_cacheable = True
is_singleton = False

def can_handle_parameter(self, parameter: Parameter) -> bool:
return parameter.annotation is RequestBody

def resolve(self, content_length: Header, body_file: RequestInput) -> RequestBody:
return RequestBody(body_file.read(int(content_length)))


class RequestDataComponent:
"""A component that parses request data.
"""

is_cacheable = True
is_singleton = False

def __init__(self, parsers: List[RequestParser]) -> None:
self.parsers = parsers

def can_handle_parameter(self, parameter: Parameter) -> bool:
return parameter.annotation is RequestData

def resolve(self, content_type: Optional[Header], resolver: DependencyResolver) -> RequestData:
content_type_str = content_type or ""
for parser in self.parsers:
if parser.can_parse_content(content_type_str.lower()):
return RequestData(resolver.resolve(parser.parse)())
raise RequestParserNotAvailable(content_type_str)
Loading

0 comments on commit 02c76ae

Please sign in to comment.