Skip to content

Commit

Permalink
Merge pull request #2 from alfred82santa/feature/runner-and-reloader
Browse files Browse the repository at this point in the history
Runner and reloader
  • Loading branch information
alfred82santa committed Sep 1, 2015
2 parents d8f06c7 + f3470f3 commit d690f70
Show file tree
Hide file tree
Showing 7 changed files with 374 additions and 4 deletions.
11 changes: 10 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

|travis-master| |coverall-master| |doc-master| |pypi-downloads| |pypi-lastrelease| |python-versions|
|project-status| |project-license| |project-format| |project-implementation|

Expand Down Expand Up @@ -74,9 +75,17 @@ Features
@flask_app.route('/')
def caller():
asyncio.ensure_future(foo_bar())
* Asyncio HTTP server runner with reload

.. code-block:: bash
$ python aiowerkzeug/serving.py --reload app_test.app
----
TODO
----

* Form parser
* Server powered by aiohttp
* Debug middleware
* Static files middleware
167 changes: 167 additions & 0 deletions aiowerkzeug/_reloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import asyncio
import os
import sys
from hachiko.hachiko import AIOEventHandler
from werkzeug._internal import _log
from werkzeug._reloader import ReloaderLoop, _find_observable_paths

__author__ = 'alfred'


EVENT_TYPE_MOVED = 'moved'
EVENT_TYPE_DELETED = 'deleted'
EVENT_TYPE_CREATED = 'created'
EVENT_TYPE_MODIFIED = 'modified'


class AIOReloaderLoop(ReloaderLoop):

def __init__(self, extra_files=None, interval=1, loop=None):
super(AIOReloaderLoop, self).__init__(extra_files=extra_files, interval=interval)
self.loop = loop
self.process = None

@asyncio.coroutine
def restart_with_reloader(self):
"""Spawn a new Python interpreter with the same arguments as this one,
but running the reloader thread.
"""
_log('info', ' * Restarting with %s' % self.name)
args = [sys.executable] + sys.argv
new_environ = os.environ.copy()
new_environ['WERKZEUG_RUN_MAIN'] = 'true'

exit_code = 3
while exit_code == 3:
self.process = yield from asyncio.create_subprocess_shell(' '.join(args), env=new_environ,
cwd=os.getcwd(),
stdout=sys.stdout)
exit_code = yield from self.process.wait()
return exit_code

def terminate(self):
if self.process and self.pid:
self.process.terminate()


class HachikoReloaderLoop(AIOReloaderLoop):

def __init__(self, *args, **kwargs):
super(HachikoReloaderLoop, self).__init__(*args, **kwargs)
from watchdog.observers import Observer
self.observable_paths = set()

@asyncio.coroutine
def _check_modification(filename):
if filename in self.extra_files:
yield from self.trigger_reload(filename)
dirname = os.path.dirname(filename)
if dirname.startswith(tuple(self.observable_paths)):
if filename.endswith(('.pyc', '.pyo')):
yield from self.trigger_reload(filename[:-1])
elif filename.endswith('.py'):
yield from self.trigger_reload(filename)

class _CustomHandler(AIOEventHandler):

@asyncio.coroutine
def on_created(self, event):
yield from _check_modification(event.src_path)

@asyncio.coroutine
def on_modified(self, event):
yield from _check_modification(event.src_path)

@asyncio.coroutine
def on_moved(self, event):
yield from _check_modification(event.src_path)
yield from _check_modification(event.dest_path)

@asyncio.coroutine
def on_deleted(self, event):
yield from _check_modification(event.src_path)

reloader_name = Observer.__name__.lower()
if reloader_name.endswith('observer'):
reloader_name = reloader_name[:-8]
reloader_name += ' reloader'

self.name = reloader_name

self.observer_class = Observer
self.event_handler = _CustomHandler(loop=self.loop)
self.should_reload = asyncio.Event(loop=self.loop)

@asyncio.coroutine
def trigger_reload(self, filename):
# This is called inside an event handler, which means we can't throw
# SystemExit here. https://github.com/gorakhargosh/watchdog/issues/294
self.should_reload.set()
filename = os.path.abspath(filename)
_log('info', ' * Detected change in %r, reloading' % filename)

@asyncio.coroutine
def run(self):
watches = {}
observer = self.observer_class()
observer.start()

to_delete = set(watches)
paths = _find_observable_paths(self.extra_files)
for path in paths:
if path not in watches:
try:
watches[path] = observer.schedule(
self.event_handler, path, recursive=True)
except OSError as e:
message = str(e)

if message != "Path is not a directory":
# Log the exception
_log('error', message)

# Clear this path from list of watches We don't want
# the same error message showing again in the next
# iteration.
watches[path] = None
to_delete.discard(path)
for path in to_delete:
watch = watches.pop(path, None)
if watch is not None:
observer.unschedule(watch)
self.observable_paths = paths

yield from self.should_reload.wait()

sys.exit(3)

def terminate(self):
pass


reloader_loops = {
'hachiko': HachikoReloaderLoop
}


reloader_loops['auto'] = reloader_loops['hachiko']


def run_with_reloader(main_func, extra_files=None, interval=1,
reloader_type='auto', loop=None):

loop = loop or asyncio.get_event_loop()

reloader = reloader_loops[reloader_type](extra_files, interval, loop=loop)

import signal
loop.add_signal_handler(signal.SIGTERM, lambda *args: loop.stop())
try:
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
main_func(loop=loop)
loop.run_until_complete(reloader.run())
else:
resultcode = loop.run_until_complete(reloader.restart_with_reloader())
sys.exit(resultcode)
except KeyboardInterrupt:
pass
174 changes: 174 additions & 0 deletions aiowerkzeug/serving.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import asyncio
import socket
import os
import sys
from aiohttp.wsgi import WSGIServerHttpProtocol
from werkzeug._internal import _log
from werkzeug.serving import select_ip_version

__author__ = 'alfred'


def make_server(host, port, app=None, threaded=False, processes=1,
request_handler=None, passthrough_errors=False,
ssl_context=None, loop=None):
if threaded or processes > 1:
raise ValueError("Multi-thread or process servers not supported.")

loop = loop or asyncio.get_event_loop()
asyncio.async(asyncio.get_event_loop().create_server(lambda: WSGIServerHttpProtocol(app, readpayload=True),
host, port),
loop=loop)


def run_simple(hostname, port, application, use_reloader=False,
use_debugger=False, use_evalex=True,
extra_files=None, reloader_interval=1,
reloader_type='auto', threaded=False, processes=1,
request_handler=None, static_files=None,
passthrough_errors=False, ssl_context=None, loop=None):
"""Start a WSGI application. Optional features include a reloader,
multithreading and fork support.
This function has a command-line interface too::
python -m werkzeug.serving --help
.. versionadded:: 0.5
`static_files` was added to simplify serving of static files as well
as `passthrough_errors`.
.. versionadded:: 0.6
support for SSL was added.
.. versionadded:: 0.8
Added support for automatically loading a SSL context from certificate
file and private key.
.. versionadded:: 0.9
Added command-line interface.
.. versionadded:: 0.10
Improved the reloader and added support for changing the backend
through the `reloader_type` parameter. See :ref:`reloader`
for more information.
:param hostname: The host for the application. eg: ``'localhost'``
:param port: The port for the server. eg: ``8080``
:param application: the WSGI application to execute
:param use_reloader: should the server automatically restart the python
process if modules were changed?
:param use_debugger: should the werkzeug debugging system be used?
:param use_evalex: should the exception evaluation feature be enabled?
:param extra_files: a list of files the reloader should watch
additionally to the modules. For example configuration
files.
:param reloader_interval: the interval for the reloader in seconds.
:param reloader_type: the type of reloader to use. The default is
auto detection. Valid values are ``'stat'`` and
``'watchdog'``. See :ref:`reloader` for more
information.
:param threaded: should the process handle each request in a separate
thread?
:param processes: if greater than 1 then handle each request in a new process
up to this maximum number of concurrent processes.
:param request_handler: optional parameter that can be used to replace
the default one. You can use this to replace it
with a different
:class:`~BaseHTTPServer.BaseHTTPRequestHandler`
subclass.
:param static_files: a dict of paths for static files. This works exactly
like :class:`SharedDataMiddleware`, it's actually
just wrapping the application in that middleware before
serving.
:param passthrough_errors: set this to `True` to disable the error catching.
This means that the server will die on errors but
it can be useful to hook debuggers in (pdb etc.)
:param ssl_context: an SSL context for the connection. Either an
:class:`ssl.SSLContext`, a tuple in the form
``(cert_file, pkey_file)``, the string ``'adhoc'`` if
the server should automatically create one, or ``None``
to disable SSL (which is the default).
"""
loop = loop or asyncio.get_event_loop()

if use_debugger:
raise NotImplemented("Debugger not implemented with asyncio")
if static_files:
raise NotImplemented("Static files not implemented with asyncio")

def inner(loop):
make_server(hostname, port, application, threaded,
processes, request_handler,
passthrough_errors, ssl_context, loop)

if os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
display_hostname = hostname != '*' and hostname or 'localhost'
if ':' in display_hostname:
display_hostname = '[%s]' % display_hostname
quit_msg = '(Press CTRL+C to quit)'
_log('info', ' * Running on %s://%s:%d/ %s', ssl_context is None
and 'http' or 'https', display_hostname, port, quit_msg)
if use_reloader:
# Create and destroy a socket so that any exceptions are raised before
# we spawn a separate Python interpreter and lose this ability.
address_family = select_ip_version(hostname, port)
test_socket = socket.socket(address_family, socket.SOCK_STREAM)
test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
test_socket.bind((hostname, port))
test_socket.close()

from ._reloader import run_with_reloader
run_with_reloader(inner, extra_files, reloader_interval,
reloader_type, loop)
else:
inner(loop)
loop.run_forever()


def run_with_reloader(*args, **kwargs):
# People keep using undocumented APIs. Do not use this function
# please, we do not guarantee that it continues working.
from ._reloader import run_with_reloader
return run_with_reloader(*args, **kwargs)


def main():
'''A simple command-line interface for :py:func:`run_simple`.'''

# in contrast to argparse, this works at least under Python < 2.7
import optparse
from werkzeug.utils import import_string

parser = optparse.OptionParser(
usage='Usage: %prog [options] app_module:app_object')
parser.add_option('-b', '--bind', dest='address',
help='The hostname:port the app should listen on.')
parser.add_option('-d', '--debug', dest='use_debugger',
action='store_true', default=False,
help='Use Werkzeug\'s debugger.')
parser.add_option('-r', '--reload', dest='use_reloader',
action='store_true', default=False,
help='Reload Python process if modules change.')
options, args = parser.parse_args()

hostname, port = None, None
if options.address:
address = options.address.split(':')
hostname = address[0]
if len(address) > 1:
port = address[1]

if len(args) != 1:
sys.stdout.write('No application supplied, or too much. See --help\n')
sys.exit(1)
app = import_string(args[0])

run_simple(
hostname=(hostname or '127.0.0.1'), port=int(port or 5000),
application=app, use_reloader=options.use_reloader,
use_debugger=options.use_debugger
)

if __name__ == '__main__':
main()
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
Werkzeug>=0.7
hachiko
aiohttp
9 changes: 6 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from setuptools import setup
import os

with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as desc_file:
description = desc_file.read().replace(':class:', '')

setup(
name='aiowerkzeug',
url='https://github.com/alfred82santa/aiowerkzeug',
author='alfred82santa',
version='0.1.0',
version='0.1.1',
author_email='alfred82santa@gmail.com',
classifiers=[
'Intended Audience :: Developers',
Expand All @@ -16,9 +19,9 @@
'Development Status :: 3 - Alpha'],
packages=['aiowerkzeug'],
include_package_data=True,
install_requires=['werkzeug'],
install_requires=['werkzeug', 'hachiko', 'aiohttp'],
description="Werkzeug for asyncio",
long_description=open(os.path.join(os.path.dirname(__file__), 'README.rst')).read(),
long_description=description,
test_suite="nose.collector",
tests_require="nose",
zip_safe=True,
Expand Down
Loading

0 comments on commit d690f70

Please sign in to comment.