Skip to content

Commit

Permalink
Async workflow tests + 404 responses support
Browse files Browse the repository at this point in the history
  • Loading branch information
sametmax authored and Damien Nicolas committed Jul 1, 2016
1 parent c6ca325 commit d392546
Show file tree
Hide file tree
Showing 15 changed files with 313 additions and 75 deletions.
2 changes: 2 additions & 0 deletions src/tygs/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,5 +160,7 @@ def _finish(self):
self.break_loop_with_error(msg)
self.state = 'stop'
self.loop.run_until_complete(self.async_stop())

# TODO : should we really close the loop ?
self.loop.close()
self.main_future.exception()
38 changes: 17 additions & 21 deletions src/tygs/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@

from aiohttp.web_reqrep import Request
from aiohttp.web import RequestHandlerFactory, RequestHandler
import werkzeug

from .utils import ensure_coroutine, ensure_awaitable
from .utils import ensure_coroutine, HTTP_VERBS
from .http.server import HttpRequestController, Router


Expand All @@ -27,12 +28,12 @@ def __init__(self, app):
self.signals = {}

def register(self, event, handler):
handler = ensure_awaitable(handler)
handler = ensure_coroutine(handler)
self.signals.setdefault(event, []).append(handler)

def trigger(self, event):
handlers = self.signals.get(event, [])
futures = (asyncio.ensure_future(handler) for handler in handlers)
futures = (asyncio.ensure_future(handler()) for handler in handlers)
return asyncio.gather(*futures)

def on(self, event):
Expand Down Expand Up @@ -76,33 +77,24 @@ def render_to_response_dict(self, response):

class HttpComponent(Component):

GET = 'GET'
POST = 'POST'
PUT = 'PUT'
PATCH = 'PATCH'
HEAD = 'HEAD'
OPTIONS = 'OPTIONS'
DELETE = 'DELETE'
methods = (GET, POST, PUT, PATCH, HEAD, OPTIONS, DELETE)

def __init__(self, app):
super().__init__(app)
self.router = Router()
for meth in self.methods:
for meth in HTTP_VERBS:
setattr(self, meth.lower(), partial(self.route, methods=[meth]))
# TODO: figure out namespace cascading from the app tree architecture

# TODO: use explicit arguments
def route(self, url, methods=None, lazy_post=False, *args, **kwargs):
def route(self, url, *args, methods=None, lazy_body=False, **kwargs):
def decorator(func):

func = ensure_coroutine(func)

@wraps(func)
async def handler_wrapper(req, res):
# if self.POST not in methods and not lazy_post:
if self.POST in methods and not lazy_post:
await req.load_post()
# if self.POST not in methods and not lazy_body:
if not lazy_body and req._aiohttp_request.has_body:
await req.load_body()
return await func(req, res)

# TODO: allow passing explicit endpoint
Expand Down Expand Up @@ -138,8 +130,11 @@ async def _tygs_request_from_message(self, message, payload):
async def _get_handler_and_tygs_req(self, message, payload):
# message contains the HTTP headers, payload contains the request body
tygs_request = await self._tygs_request_from_message(message, payload)
handler, arguments = await self._router.get_handler(tygs_request)
tygs_request.url_params.update(arguments)
try:
handler, arguments = await self._router.get_handler(tygs_request)
tygs_request.url_args.update(arguments)
except werkzeug.exceptions.NotFound as e:
handler = self._router.get_404_handler(e)
return tygs_request, handler

async def handle_request(self, message, payload):
Expand Down Expand Up @@ -190,12 +185,13 @@ async def _write_response_to_client(self, request, response):


# I'm so sorry
def aiohttp_request_handler_factory_adapter_factory(app):
def aiohttp_request_handler_factory_adapter_factory(
app, *, handler_adapter=AioHttpRequestHandlerAdapter):

class AioHttpRequestHandlerFactoryAdapter(RequestHandlerFactory):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._handler = partial(AioHttpRequestHandlerAdapter, tygs_app=app)
self._handler = partial(handler_adapter, tygs_app=app)
self._router = app.components['http'].router
self._loop = asyncio.get_event_loop()
return AioHttpRequestHandlerFactoryAdapter
8 changes: 8 additions & 0 deletions src/tygs/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@


class TygsError(Exception):
pass


class HttpRequestControllerError(TygsError):
pass
135 changes: 123 additions & 12 deletions src/tygs/http/server.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@

import asyncio

from textwrap import dedent

from aiohttp.web_reqrep import Response
from aiohttp.helpers import reify

from werkzeug.routing import Map, Rule

from tygs.exceptions import HttpRequestControllerError
from tygs.utils import HTTP_VERBS, removable_property


# TODO: move this function and other renderers to a dedicated module
def text_renderer(response):
body = response.data['__text__'].encode(response.charset)

return {'status': response.status,
'reason': response.reason,
'content_type': 'text/plain',
'charset': response.charset,
# TODO: update default heaers
'headers': response.headers,
'body': body
}


class HttpRequestController:

Expand All @@ -13,26 +33,104 @@ class HttpRequestController:
def __init__(self, app, aiohttp_request):
self.app = app
self._aiohttp_request = aiohttp_request

self.server_name = aiohttp_request.host
self.script_name = None # TODO : figure out what script name does
self.subdomain = None # TODO: figure out subdomain handling
self.url_scheme = aiohttp_request.scheme
self.method = aiohttp_request.method
self.path_info = aiohttp_request.path
self.query_args = aiohttp_request.query_string
self.response = HttpResponseController(self)
self.url_params = {}

self.url_args = {}
self.url_scheme = aiohttp_request.scheme
self.url_path = aiohttp_request.path

def __repr__(self):
return "<{} {} {!r} >".format(self.__class__.__name__,
self.method, self.path_info)
self.method, self.url_path)

# TODO: headers
# TODO: cookies
# TODO: raw data (query string, path, etc)

def __getitem__(self, name):

try:
return self.url_query[name]
except KeyError:
pass

try:
return self.body[name]
except KeyError:
pass

return super().__getitem__(self, name)

def __iter__(self):
for x in self.url_query:
yield x
for x in self.body:
yield x

def items(self):
for x in self.url_query.items():
yield x
for x in self.body.items():
yield x

def values(self):
for x in self.url_query.values():
yield x
for x in self.body.values():
yield x

def __contains__(self, name):
return name in self.url_query or name in self.body

def __len__(self, name):
return len(self.url_query) + len(self.body)

def __getattr__(self, name):

if name == "GET":
raise HttpRequestControllerError(dedent("""
There is no "GET{0}" attribute. If you are looking for the
data passed as the URL query sting (usually $_GET, .GET, etc.),
use the "query_args" attribute.
"""))

if name in HTTP_VERBS:
raise HttpRequestControllerError(dedent("""
There is no "{0}" attribute. If you are looking for the
request body (usually called $_POST, $_GET, etc.), use the
"body" attribute. It works with all HTTP verbs and not
just "{0}".
""".format(name)))

@reify
def url_query(self):
return self._aiohttp_request.GET

@removable_property
def body(self):
raise HttpRequestControllerError(dedent("""
You must await "HttpRequestController.load_body()" before accessing
"HttpRequestController.body".
This error can happen when you read "req.body" or
"req['something']" (because it looks up in "req.body"
after "req.query_args").
"HttpRequestController.load_body()" is called automatically unless
you used "lazy_body=True" in your routing code, so check it out.
"""))

async def load_body(self):
body = await self._aiohttp_request.post()
HttpRequestController.body.replace_property_with(self, body)
return body

async def load_post(self):
post = self._aiohttp_request.post()
self.POST = post
return post

# TODO: add aiohttp_request.POST and aiohttp_request.GET
# TODO: cookies
# TODO: send log info about multidict values: the user should know if she tries
# to access a single value from a multidict that has multiple values
Expand Down Expand Up @@ -67,6 +165,10 @@ def template(self, template, context=None):

return self

def text(self, message):
self.data['__text__'] = str(message)
self.renderer = text_renderer

def render_response(self):
return self.renderer(self)

Expand All @@ -93,14 +195,23 @@ async def get_handler(self, http_request):
subdomain=http_request.subdomain,
url_scheme=http_request.url_scheme,
default_method=http_request.method,
path_info=http_request.path_info,
query_args=http_request.query_args
path_info=http_request.url_path,
query_args=http_request.url_query
)

# TODO: handle NotFound, MethodNotAllowed, RequestRedirect exceptions
endpoint, arguments = map_adapter.match()
return self.handlers[endpoint], arguments

def get_404_handler(self, exception):

async def handle_404(req, res):
res.status = 404
res.reason = 'Not found'
res.text(exception)

return handle_404


class Server:

Expand Down
9 changes: 5 additions & 4 deletions src/tygs/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@

class AsyncMock(Mock):

def __call__(self, *args, **kwargs):
async def __call__(self, *args, **kwargs):
parent = super(AsyncMock, self)
async def coro():
return parent.__call__(*args, **kwargs)
return coro()
return parent.__call__(*args, **kwargs)
# async def coro():
# return parent.__call__(*args, **kwargs)
# return coro()

def __await__(self):
return self().__await__()
43 changes: 42 additions & 1 deletion src/tygs/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import os
import asyncio
import inspect

import contextlib

from path import Path
Expand All @@ -23,6 +22,10 @@ def ensure_coroutine(callable_obj):
if inspect.iscoroutinefunction(callable_obj):
return callable_obj

if hasattr(callable_obj, '__call__') and \
inspect.iscoroutinefunction(callable_obj.__call__):
return callable_obj

# If it's a normal callable is passed, wrap it as a coroutine.
return asyncio.coroutine(callable_obj)

Expand Down Expand Up @@ -92,3 +95,41 @@ def silence_loop_error_log(loop):
loop.set_exception_handler(lambda loop, context: None)
yield
loop.set_exception_handler(old_handler)


HTTP_VERBS = (
'GET',
'POST',
'PUT',
'PATCH',
'HEAD',
'OPTIONS',
'DELETE'
)


class removable_property:
""" like @property but the method can be replace by a regular value later
Code inspired by aiohttp's reify
https://github.com/KeepSafe/aiohttp/blob/master/aiohttp/helpers.py
"""

def __init__(self, wrapped):
self.wrapped = wrapped
self.__doc__ = getattr(wrapped, '__doc__', '')
self.name = wrapped.__name__

def __get__(self, inst, owner, _marker=object()):
if inst is None:
return self
val = inst.__dict__.get(self.name, _marker)
if val is not _marker:
return val
return self.wrapped(inst)

def __set__(self, inst, value):
raise AttributeError("removable_property is read-only")

def replace_property_with(self, obj, value):
obj.__dict__[self.name] = value
6 changes: 2 additions & 4 deletions src/tygs/webapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,20 @@
from .app import App

from .components import (HttpComponent,
aiohttp_request_handler_factory_adapter_factory,
aiohttp_request_handler_factory_adapter_factory as rh,
Jinja2Renderer)

from .http.server import Server


class WebApp(App):

def __init__(self, *args, **kwargs):
def __init__(self, *args, factory_adapter=rh, **kwargs):
super().__init__(*args, **kwargs)
self.components['http'] = HttpComponent(self)
self.components['templates'] = Jinja2Renderer(self)
self.http_server = None

# aliasing this stupidly long name
factory_adapter = aiohttp_request_handler_factory_adapter_factory
self._aiohttp_app = Application(
handler_factory=factory_adapter(self)
)
Expand Down
Loading

0 comments on commit d392546

Please sign in to comment.