diff --git a/examples/getting_started/Introduction.ipynb b/examples/getting_started/Introduction.ipynb
index 37396ae4d6..151ca045bd 100644
--- a/examples/getting_started/Introduction.ipynb
+++ b/examples/getting_started/Introduction.ipynb
@@ -205,7 +205,7 @@
"\n",
"You can use this compositional approach to combine different components such as widgets, plots, text, and other elements needed for an app or dashboard in arbitrary ways. The ``interact`` example builds on a reactive programming model, where an input to the function changes and Panel reactively updates the output of the function. ``interact`` is a convenient way to create widgets from the arguments to your function automatically, but Panel also provides a more explicit reactive API letting you specifically define connections between widgets and function arguments, and then lets you compose the resulting dashboard manually from scratch.\n",
"\n",
- "In the example below we explicitly declare each of the components of an app: widgets, a function to return the plot, column and row containers, and the completed `occupancy` Panel app. Widget objects have multiple \"parameters\" (current value, allowed ranges, and so on), and here we will use Panel's ``depends`` decorator to declare that function's input values should come from the widgets' ``value`` parameters. Now when the function and the widgets are displayed, Panel will automatically update the displayed output whenever any of the inputs change:"
+ "In the example below we explicitly declare each of the components of an app: widgets, a function to return the plot, column and row containers, and the completed `occupancy` Panel app. Widget objects have multiple \"parameters\" (current value, allowed ranges, and so on), and here we will use Panel's ``bind`` function to declare that function's input values should come from the widgets' ``value`` parameters. Now when the function and the widgets are displayed, Panel will automatically update the displayed output whenever any of the inputs change:"
]
},
{
@@ -220,9 +220,7 @@
" options=list(data.columns))\n",
"window = pnw.IntSlider(name='window', value=10, start=1, end=60)\n",
"\n",
- "@pn.depends(variable, window)\n",
- "def reactive_outliers(variable, window):\n",
- " return find_outliers(variable, window, 10)\n",
+ "reactive_outliers = pn.bind(find_outliers, variable, window, 10)\n",
"\n",
"widgets = pn.Column(\"
\\n# Room occupancy\", variable, window)\n",
"occupancy = pn.Row(reactive_outliers, widgets)\n",
@@ -263,6 +261,13 @@
"occupancy.servable();"
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "During development, particularly when working with a raw script using `panel serve --show --autoreload` can be very useful as the application will automatically update whenever the script or notebook or any of its imports change."
+ ]
+ },
{
"cell_type": "markdown",
"metadata": {},
diff --git a/examples/user_guide/Deploy_and_Export.ipynb b/examples/user_guide/Deploy_and_Export.ipynb
index c5cc1cc47c..aad90a0f21 100644
--- a/examples/user_guide/Deploy_and_Export.ipynb
+++ b/examples/user_guide/Deploy_and_Export.ipynb
@@ -20,7 +20,7 @@
"\n",
"## Configuring output\n",
"\n",
- "As you may have noticed, almost all the Panel documentation is written using notebooks. Panel objects display themselves automatically in a notebook and take advantage of Jupyter Comms to support communication between the rendered app and the Jupyter kernel that backs it on the Python end. To display a Panel object in the notebook is as simple as putting it on the end of a cell. Note, however, that the ``panel.extension`` first has to be loaded to initialize the required JavaScript in the notebook context. Also, if you are working in JupyterLab, the pyviz labextension has to be installed with:\n",
+ "As you may have noticed, almost all the Panel documentation is written using notebooks. Panel objects display themselves automatically in a notebook and take advantage of Jupyter Comms to support communication between the rendered app and the Jupyter kernel that backs it on the Python end. To display a Panel object in the notebook is as simple as putting it on the end of a cell. Note, however, that the ``panel.extension`` first has to be loaded to initialize the required JavaScript in the notebook context. In recent versions of JupyterLab this works out of the box but for older versions (`<3.0`) the PyViz labextension has to be installed with:\n",
"\n",
" jupyter labextension install @pyviz/jupyterlab_pyviz\n",
"\n",
@@ -292,6 +292,8 @@
"\n",
" panel serve apps/*.py\n",
" \n",
+ "For development it can be particularly helpful to use the ``--autoreload`` option to `panel serve` as that will automatically reload the page whenever the application code or any of its imports change.\n",
+ " \n",
"The ``panel serve`` command has the following options:\n",
"\n",
" positional arguments:\n",
@@ -354,6 +356,8 @@
" --num-procs N Number of worker processes for an app. Using 0 will\n",
" autodetect number of cores (defaults to 1)\n",
" --warm Whether to execute scripts on startup to warm up the server.\n",
+ " --autoreload\n",
+ " Whether to automatically reload user sessions when the application or any of its imports change.\n",
" --static-dirs KEY=VALUE [KEY=VALUE ...] \n",
" Static directories to serve specified as key=value\n",
" pairs mapping from URL route to static file directory.\n",
diff --git a/panel/command/serve.py b/panel/command/serve.py
index 00155d1c76..1195945753 100644
--- a/panel/command/serve.py
+++ b/panel/command/serve.py
@@ -16,6 +16,7 @@
from ..auth import OAuthProvider
from ..config import config
from ..io.rest import REST_PROVIDERS
+from ..io.reload import record_modules, watch
from ..io.server import INDEX_HTML, get_static_routes
from ..io.state import state
from ..util import edit_readonly
@@ -116,6 +117,10 @@ class Serve(_BkServe):
('--warm', dict(
action = 'store_true',
help = "Whether to execute scripts on startup to warm up the server."
+ )),
+ ('--autoreload', dict(
+ action = 'store_true',
+ help = "Whether to autoreload source when script changes."
))
)
@@ -148,12 +153,6 @@ def customize_kwargs(self, args, server_kwargs):
with edit_readonly(state):
state.base_url = urljoin('/', prefix)
- if args.warm:
- argvs = {f: args.args for f in files}
- applications = build_single_handler_applications(files, argvs)
- for app in applications.values():
- app.create_document()
-
# Handle tranquilized functions in the supplied functions
if args.rest_provider in REST_PROVIDERS:
pattern = REST_PROVIDERS[args.rest_provider](files, args.rest_endpoint)
@@ -161,6 +160,19 @@ def customize_kwargs(self, args, server_kwargs):
elif args.rest_provider is not None:
raise ValueError("rest-provider %r not recognized." % args.rest_provider)
+ config.autoreload = args.autoreload
+
+ if config.autoreload:
+ for f in files:
+ watch(f)
+
+ if args.warm or args.autoreload:
+ argvs = {f: args.args for f in files}
+ applications = build_single_handler_applications(files, argvs)
+ with record_modules():
+ for app in applications.values():
+ app.create_document()
+
config.session_history = args.session_history
if args.rest_session_info:
pattern = REST_PROVIDERS['param'](files, 'rest')
diff --git a/panel/config.py b/panel/config.py
index f5f64f736a..31cdf619f7 100644
--- a/panel/config.py
+++ b/panel/config.py
@@ -80,6 +80,9 @@ class _config(_base_config):
Whether to set custom Signature which allows tab-completion
in some IDEs and environments.""")
+ autoreload = param.Boolean(default=False, doc="""
+ Whether to autoreload server when script changes.""")
+
safe_embed = param.Boolean(default=False, doc="""
Ensure all bokeh property changes trigger events which are
embedded. Useful when only partial updates are made in an
diff --git a/panel/io/reload.py b/panel/io/reload.py
new file mode 100644
index 0000000000..914195258c
--- /dev/null
+++ b/panel/io/reload.py
@@ -0,0 +1,150 @@
+import fnmatch
+import os
+import sys
+import types
+
+from contextlib import contextmanager
+from functools import partial
+
+from .callbacks import PeriodicCallback
+from .state import state
+
+_watched_files = set()
+_modules = set()
+_callbacks = {}
+
+# List of paths to ignore
+DEFAULT_FOLDER_BLACKLIST = [
+ "**/.*",
+ "**/anaconda",
+ "**/anaconda2",
+ "**/anaconda3",
+ "**/dist-packages",
+ "**/miniconda",
+ "**/miniconda2",
+ "**/miniconda3",
+ "**/node_modules",
+ "**/pyenv",
+ "**/site-packages",
+ "**/venv",
+ "**/virtualenv",
+]
+
+
+def in_blacklist(filepath):
+ return any(
+ file_is_in_folder_glob(filepath, blacklisted_folder)
+ for blacklisted_folder in DEFAULT_FOLDER_BLACKLIST
+ )
+
+def file_is_in_folder_glob(filepath, folderpath_glob):
+ """
+ Test whether a file is in some folder with globbing support.
+
+ Parameters
+ ----------
+ filepath : str
+ A file path.
+ folderpath_glob: str
+ A path to a folder that may include globbing.
+ """
+ # Make the glob always end with "/*" so we match files inside subfolders of
+ # folderpath_glob.
+ if not folderpath_glob.endswith("*"):
+ if folderpath_glob.endswith("/"):
+ folderpath_glob += "*"
+ else:
+ folderpath_glob += "/*"
+
+ file_dir = os.path.dirname(filepath) + "/"
+ return fnmatch.fnmatch(file_dir, folderpath_glob)
+
+def autoreload_watcher():
+ """
+ Installs a periodic callback which checks for changes in watched
+ files and sys.modules.
+ """
+ cb = partial(_reload_on_update, {})
+ _callbacks[state.curdoc] = pcb = PeriodicCallback(callback=cb)
+ pcb.start()
+
+def watch(filename):
+ """
+ Add a file to the watch list.
+
+ All imported modules are watched by default.
+ """
+ _watched_files.add(filename)
+
+@contextmanager
+def record_modules():
+ """
+ Records modules which are currently imported.
+ """
+ modules = set(sys.modules)
+ yield
+ if _modules:
+ return
+ for module_name in set(sys.modules).difference(modules):
+ if module_name.startswith('bokeh_app'):
+ continue
+ module = sys.modules[module_name]
+ try:
+ spec = getattr(module, "__spec__", None)
+ if spec is None:
+ filepath = getattr(module, "__file__", None)
+ if filepath is None: # no user
+ continue
+ else:
+ filepath = spec.origin
+
+ filepath = os.path.abspath(filepath)
+
+ if filepath is None or in_blacklist(filepath):
+ continue
+
+ if not os.path.isfile(filepath): # e.g. built-in
+ continue
+ _modules.add(module_name)
+ except Exception:
+ continue
+
+def _reload(module=None):
+ if module is not None:
+ for module in _modules:
+ del sys.modules[module]
+ if state.curdoc in _callbacks:
+ _callbacks[state.curdoc].stop()
+ del _callbacks[state.curdoc]
+ if state.location:
+ state.location.reload = True
+
+def _check_file(modify_times, path, module=None):
+ try:
+ modified = os.stat(path).st_mtime
+ except Exception:
+ return
+ if path not in modify_times:
+ modify_times[path] = modified
+ return
+ if modify_times[path] != modified:
+ _reload(module)
+ modify_times[path] = modified
+
+def _reload_on_update(modify_times):
+ for module_name in _modules:
+ # Some modules play games with sys.modules (e.g. email/__init__.py
+ # in the standard library), and occasionally this can cause strange
+ # failures in getattr. Just ignore anything that's not an ordinary
+ # module.
+ module = sys.modules[module_name]
+ 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]
+ _check_file(modify_times, path, module_name)
+ for path in _watched_files:
+ _check_file(modify_times, path)
diff --git a/panel/io/server.py b/panel/io/server.py
index dcb85b84c6..009902a2d7 100644
--- a/panel/io/server.py
+++ b/panel/io/server.py
@@ -2,11 +2,13 @@
Utilities for creating bokeh Server instances.
"""
import datetime as dt
+import html
import inspect
import os
import pathlib
import signal
import sys
+import traceback
import threading
import uuid
@@ -21,6 +23,7 @@
# Bokeh imports
from bokeh.application import Application as BkApplication
+from bokeh.application.handlers.code import CodeHandler
from bokeh.application.handlers.function import FunctionHandler
from bokeh.command.util import build_single_handler_application
from bokeh.document.events import ModelChangedEvent
@@ -39,6 +42,7 @@
from tornado.wsgi import WSGIContainer
# Internal imports
+from .reload import autoreload_watcher
from .resources import BASE_TEMPLATE, Bundle, Resources
from .state import state
@@ -174,6 +178,71 @@ async def get(self, *args, **kwargs):
per_app_patterns[0] = (r'/?', DocHandler)
+def modify_document(self, doc):
+ from bokeh.io.doc import set_curdoc as bk_set_curdoc
+ from ..config import config
+
+ if config.autoreload:
+ path = self._runner.path
+ argv = self._runner._argv
+ handler = type(self)(filename=path, argv=argv)
+ self._runner = handler._runner
+
+ module = self._runner.new_module()
+
+ # If no module was returned it means the code runner has some permanent
+ # unfixable problem, e.g. the configured source code has a syntax error
+ if module is None:
+ return
+
+ # One reason modules are stored is to prevent the module
+ # from being gc'd before the document is. A symptom of a
+ # gc'd module is that its globals become None. Additionally
+ # stored modules are used to provide correct paths to
+ # custom models resolver.
+ sys.modules[module.__name__] = module
+ doc._modules.append(module)
+
+ old_doc = curdoc()
+ bk_set_curdoc(doc)
+ old_io = self._monkeypatch_io()
+
+ if config.autoreload:
+ set_curdoc(doc)
+ state.onload(autoreload_watcher)
+
+ try:
+ def post_check():
+ newdoc = curdoc()
+ # script is supposed to edit the doc not replace it
+ if newdoc is not doc:
+ raise RuntimeError("%s at '%s' replaced the output document" % (self._origin, self._runner.path))
+
+ def handle_exception(handler, e):
+ from bokeh.application.handlers.handler import handle_exception
+ from ..pane import HTML
+
+ # Clean up
+ del sys.modules[module.__name__]
+ doc._modules.remove(module)
+ bokeh.application.handlers.code_runner.handle_exception = handle_exception
+ tb = html.escape(traceback.format_exc())
+
+ # Serve error
+ HTML(
+ f'{type(e).__name__}: {e}
{tb}', + css_classes=['alert', 'alert-danger'], sizing_mode='stretch_width' + ).servable() + + if config.autoreload: + bokeh.application.handlers.code_runner.handle_exception = handle_exception + self._runner.run(module, post_check) + finally: + self._unmonkeypatch_io(old_io) + bk_set_curdoc(old_doc) + +CodeHandler.modify_document = modify_document + #--------------------------------------------------------------------- # Public API #--------------------------------------------------------------------- diff --git a/panel/tests/io/reload_module.py b/panel/tests/io/reload_module.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/panel/tests/io/test_reload.py b/panel/tests/io/test_reload.py new file mode 100644 index 0000000000..8a62ed97b1 --- /dev/null +++ b/panel/tests/io/test_reload.py @@ -0,0 +1,53 @@ +import os + +from panel.io.location import Location +from panel.io.reload import ( + _check_file, _modules, _reload_on_update, _watched_files, + in_blacklist, record_modules, watch +) +from panel.io.state import state + +def test_record_modules(): + with record_modules(): + import panel.tests.io.reload_module # noqa + assert _modules == {'panel.tests.io.reload_module'} + _modules.clear() + +def test_record_modules_not_stdlib(): + with record_modules(): + import audioop # noqa + assert _modules == set() + _modules.clear() + +def test_check_file(): + modify_times = {} + _check_file(modify_times, __file__) + assert modify_times[__file__] == os.stat(__file__).st_mtime + +def test_file_in_blacklist(): + filepath = '/home/panel/lib/python/site-packages/panel/__init__.py' + assert in_blacklist(filepath) + filepath = '/home/panel/.config/panel.py' + assert in_blacklist(filepath) + filepath = '/home/panel/development/panel/__init__.py' + assert not in_blacklist(filepath) + +def test_watch(): + filepath = os.path.abspath(__file__) + watch(filepath) + assert _watched_files == {filepath} + # Cleanup + _watched_files.clear() + +def test_reload_on_update(): + location = Location() + state._location = location + filepath = os.path.abspath(__file__) + watch(filepath) + modify_times = {filepath: os.stat(__file__).st_mtime-1} + _reload_on_update(modify_times) + assert location.reload + + # Cleanup + _watched_files.clear() + state._location = None