Skip to content

Commit

Permalink
Automatically reload dev server when files change
Browse files Browse the repository at this point in the history
This adds a reloader to chalice local that's enabled by
default.  A new library, watchdog, is pulled in as a dependency
of chalice.  This allows us to efficiently monitor files
for changes using whatever the most efficient OS specific mechanism
(FSEvents, inotify, etc).

The general approach is borrowed from other frameworks such as
django and flask but the implementation is somewhat different.
Two processes are spun up, a worker process and a monitoring
process.  The monitoring process spawns the worker process and
restarts it if it exits with a special RC.  The worker process
launches the dev server in one thread and a file monitor in
a separate thread.  When files are detected it shuts down the
server and exits with a special RC.

There's a few differences from the other approaches:

* Avoid the use of sys.exit() directly in the reloader.  This
  made the code hard to test and seems odd that a library
  function is calling sys.exit().  However there is a call
  to `sys.exit()` in the `local()` function because this is
  the recommended way in click for a command to exit with
  a specific RC (see the comments for more info).
* We only monitor the project dir for changes.  The other
  reloaders appear to monitor everything in sys.path, which
  seems like overkill here.  The way I see it, stuff on
  sys.path would only be modified if you're installing new
  packages, in which case you'll have to modify requirements.txt
  as well as something in your app.py (in order to import the
  newly installed module).  I dug around their repos for a bit
  and couldn't find a rationale as to why that much
  monitoring is needed.

A note on coverage: because the reloader works by launching
child processes, this isn't going to be tracked via
coverage, so I expect codecov to fail.
There is some support for tracking coverage across subprocesses, but it
involved messing with sitecustomize.py or .pth files.
See http://coverage.readthedocs.io/en/coverage-4.2/subprocess.html
for more info on that.

Supercedes aws#706.
  • Loading branch information
jamesls committed May 18, 2018
1 parent fdc704a commit 033f7c4
Show file tree
Hide file tree
Showing 15 changed files with 347 additions and 224 deletions.
2 changes: 1 addition & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ valid-metaclass-classmethod-first-arg=mcs

# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,_fields,_replace,_source,_make,_thread
exclude-protected=_asdict,_fields,_replace,_source,_make


[DESIGN]
Expand Down
138 changes: 36 additions & 102 deletions chalice/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,17 @@
import sys
import tempfile
import shutil
<<<<<<< HEAD
<<<<<<< HEAD
import traceback
import functools

import botocore.exceptions
import click
from typing import Dict, Any, Optional # noqa
=======
import time
import subprocess
import threading
import types
=======
>>>>>>> Extract reloader into separate module

import botocore.exceptions
import click
from typing import Dict, Any, Optional, MutableMapping # noqa
<<<<<<< HEAD
from six.moves import _thread
>>>>>>> Add auto reloading stuff
=======
>>>>>>> Extract reloader into separate module

from chalice import __version__ as chalice_version
from chalice.app import Chalice # noqa
from chalice.awsclient import TypedAWSClient
from chalice.cli.factory import CLIFactory
from chalice.cli.reload import Reloader
from chalice.config import Config # noqa
from chalice.logs import display_logs
from chalice.utils import create_zip_file
Expand All @@ -45,7 +27,7 @@
from chalice.constants import CONFIG_VERSION, TEMPLATE_APP, GITIGNORE
from chalice.constants import DEFAULT_STAGE_NAME
from chalice.constants import DEFAULT_APIGATEWAY_STAGE_NAME
from chalice.constants import DEFAULT_AUTORELOAD_INTERVAL
from chalice.local import LocalDevServer # noqa


def create_new_project_skeleton(project_name, profile=None):
Expand Down Expand Up @@ -97,88 +79,40 @@ def cli(ctx, project_dir, debug=False):
@click.option('--port', default=8000, type=click.INT)
@click.option('--stage', default=DEFAULT_STAGE_NAME,
help='Name of the Chalice stage for the local server to use.')
@click.option('--autoreload-interval', default=DEFAULT_AUTORELOAD_INTERVAL,
type=click.INT)
@click.option('--no-autoreload', is_flag=True)
@click.option('--autoreload/--no-autoreload',
default=True,
help='Automatically restart server when code changes.')
@click.pass_context
def local(ctx, host='127.0.0.1', port=8000, stage=DEFAULT_STAGE_NAME,
autoreload_interval=DEFAULT_AUTORELOAD_INTERVAL,
no_autoreload=False):
# type: (click.Context, str, int, str, int, bool) -> None
autoreload=True):
# type: (click.Context, str, int, str, bool) -> None
factory = ctx.obj['factory'] # type: CLIFactory
<<<<<<< HEAD
<<<<<<< HEAD
from chalice.cli import reloader
# We don't create the server here because that will bind the
# socket and we only want to do this in the worker process.
server_factory = functools.partial(
create_local_server, factory, host, port, stage)
# When running `chalice local`, a stdout logger is configured
# so you'll see the same stdout logging as you would when
# running in lambda. This is configuring the root logger.
# The app-specific logger (app.log) will still continue
# to work.
logging.basicConfig(
stream=sys.stdout, level=logging.INFO, format='%(message)s')
if autoreload:
project_dir = factory.create_config_obj(
chalice_stage_name=stage).project_dir
rc = reloader.run_with_reloader(
server_factory, os.environ, project_dir)
# Click doesn't sys.exit() with the RC this function. The
# recommended way to do this is to use sys.exit() directly,
# see: https://github.com/pallets/click/issues/747
sys.exit(rc)
run_local_server(factory, host, port, stage)
=======
with Reloader(autoreload):
=======
if no_autoreload:
run_local_server(factory, host, port, stage, os.environ)
<<<<<<< HEAD
with Reloader():
>>>>>>> Enable autoreloading by default
=======
with Reloader(autoreload_interval):
>>>>>>> Add --autoreload-interval parameter
run_local_server(factory, host, port, stage, os.environ)


<<<<<<< HEAD
class Reloader(threading.Thread):
def __init__(self, autoreload=True):
super(Reloader, self).__init__()
self.autoreload = autoreload
self.triggered = False
self.mtimes = {}

def __enter__(self):
if self.autoreload:
self.setDaemon(True)
self.start()

def __exit__(self, exc_type, exc_value, traceback):
if exc_type is KeyboardInterrupt and self.triggered:
sys.exit(1)

def run(self):
while True:
time.sleep(1)
if self.is_changes():
self.reload()

def is_changes(self):
for module in list(sys.modules.values()):
if not isinstance(module, types.ModuleType):
continue
path = getattr(module, '__file__', None)
if not path:
continue
if path.endswith('.pyc') or path.endswith('.pyo'):
path = path[:-1]
try:
mtime = os.path.getmtime(path)
except OSError:
continue
old_time = self.mtimes.setdefault(path, mtime)
if mtime > old_time:
return True

def reload(self):
self.triggered = True
if sys.platform == 'win32':
subprocess.Popen(sys.argv, close_fds=True)
_thread.interrupt_main()
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
>>>>>>> Add auto reloading stuff


def run_local_server(factory, host, port, stage):
# type: (CLIFactory, str, int, str) -> None
=======
def run_local_server(factory, host, port, stage, env):
# type: (CLIFactory, str, int, str, MutableMapping) -> None
>>>>>>> Extract reloader into separate module
def create_local_server(factory, host, port, stage):
# type: (CLIFactory, str, int, str) -> LocalDevServer
config = factory.create_config_obj(
chalice_stage_name=stage
)
Expand All @@ -187,13 +121,13 @@ def run_local_server(factory, host, port, stage, env):
# there is no point in testing locally.
routes = config.chalice_app.routes
validate_routes(routes)
# When running `chalice local`, a stdout logger is configured
# so you'll see the same stdout logging as you would when
# running in lambda. This is configuring the root logger.
# The app-specific logger (app.log) will still continue
# to work.
logging.basicConfig(stream=sys.stdout)
server = factory.create_local_server(app_obj, config, host, port)
return server


def run_local_server(factory, host, port, stage):
# type: (CLIFactory, str, int, str) -> None
server = create_local_server(factory, host, port, stage)
server.serve_forever()


Expand Down
64 changes: 0 additions & 64 deletions chalice/cli/reload.py

This file was deleted.

Loading

0 comments on commit 033f7c4

Please sign in to comment.