diff --git a/README.md b/README.md index 2e926ad..f64a675 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,20 @@ Appmode adds the following [configuration options](https://jupyter-notebook.read - `Appmode.trusted_path` Run only notebooks below this path in Appmode. Default: No restrictions. - `Appmode.show_edit_button` Show _Edit App_ button during Appmode. Default: True. - `Appmode.show_other_buttons` Show other buttons, e.g. Logout, during Appmode. Default: True. +- `Appmode.temp_dir` Create temp notebooks under this directory. Default: Same directory as current notebook +- `Appmode.hidden_temp_files` Create temp notebooks as hidden files. Default: True + +Writing to hidden files is disabled in newer versions of notebook. You will need to enable one of the following +configuration options to run appmode: +``` +ContentsManager.allow_hidden=True +or +Appmode.hidden_temp_files=False +``` +Example: +``` +jupyter notebook --Appmode.hidden_temp_files=False +``` ## Client Side Customization diff --git a/appmode/server_extension.py b/appmode/server_extension.py index a68129a..717e149 100644 --- a/appmode/server_extension.py +++ b/appmode/server_extension.py @@ -1,15 +1,29 @@ # -*- coding: utf-8 -*- import os +import inspect import itertools from notebook.utils import url_path_join from notebook.base.handlers import IPythonHandler, FilesRedirectHandler, path_regex import notebook.notebook.handlers as orig_handler -from tornado import web, gen +import notebook +from tornado import web from traitlets.config import LoggingConfigurable from traitlets import Bool, Unicode +async def await_if_awaitable(obj): + """Convert a non-awaitable object to a coroutine if needed, + and await if it was a coroutine. + + Designed to be called on the result of calling a function, + when that function could be asynchronous or not. + """ + if inspect.isawaitable(obj): + obj = await obj + return obj + + class Appmode(LoggingConfigurable): """Object containing server-side configuration settings for Appmode. Defined separately from the AppmodeHandler to avoid multiple inheritance. @@ -17,6 +31,8 @@ class Appmode(LoggingConfigurable): trusted_path = Unicode('', help="Run only notebooks below this path in Appmode.", config=True) show_edit_button = Bool(True, help="Show Edit App button during Appmode.", config=True) show_other_buttons = Bool(True, help="Show other buttons, e.g. Logout, during Appmode.", config=True) + temp_dir = Unicode('', help="Create temporary Appmode notebooks in this directory.", config=True) + hidden_temp_files = Bool(True, help="Temporary Appmode notebooks are hidden files.", config=True) #=============================================================================== class AppmodeHandler(IPythonHandler): @@ -32,12 +48,19 @@ def show_edit_button(self): def show_other_buttons(self): return self.settings['appmode'].show_other_buttons + @property + def temp_dir(self): + return self.settings['appmode'].temp_dir + + @property + def hidden_temp_files(self): + return self.settings['appmode'].hidden_temp_files + #=========================================================================== @web.authenticated - def get(self, path): + async def get(self, path): """get renders the notebook template if a name is given, or redirects to the '/files/' handler if the name is not given.""" - path = path.strip('/') self.log.info('Appmode get: %s', path) @@ -47,10 +70,9 @@ def get(self, path): raise web.HTTPError(401, 'Notebook is not within trusted Appmode path.') cm = self.contents_manager - # will raise 404 on not found try: - model = cm.get(path, content=False) + model = await await_if_awaitable(cm.get(path, content=False)) except web.HTTPError as e: if e.status_code == 404 and 'files' in path.split('/'): # 404, but '/files/' in URL, let FilesRedirect take care of it @@ -65,7 +87,7 @@ def get(self, path): self.add_header("Cache-Control", "cache-control: private, max-age=0, no-cache, no-store") # gather template parameters - tmp_path = self.mk_tmp_copy(path) + tmp_path = await self.mk_tmp_copy(path) tmp_name = tmp_path.rsplit('/', 1)[-1] render_kwargs = { 'notebook_path': tmp_path, @@ -87,52 +109,55 @@ def get(self, path): #=========================================================================== @web.authenticated - @gen.coroutine - def post(self, path): + async def post(self, path): assert self.get_body_arguments("appmode_action")[0] == "delete" path = path.strip('/') self.log.info('Appmode deleting: %s', path) # delete session, including the kernel sm = self.session_manager - if gen.is_coroutine_function(sm.get_session): - s = yield sm.get_session(path=path) - else: - s = sm.get_session(path=path) - if gen.is_coroutine_function(sm.delete_session): - yield sm.delete_session(session_id=s['id']) - else: - sm.delete_session(session_id=s['id']) + + s = await await_if_awaitable(sm.get_session(path=path)) + await await_if_awaitable(sm.delete_session(session_id=s['id'])) # delete tmp copy cm = self.contents_manager - cm.delete(path) - self.finish() + await await_if_awaitable(cm.delete(path)) + await self.finish() #=========================================================================== - def mk_tmp_copy(self, path): + async def mk_tmp_copy(self, path): cm = self.contents_manager - # find tmp_path - dirname = os.path.dirname(path) + dirname = self.temp_dir or os.path.dirname(path) + if dirname and not os.path.exists(dirname): + os.makedirs(dirname) fullbasename = os.path.basename(path) basename, ext = os.path.splitext(fullbasename) + if self.hidden_temp_files: + basename = '.' + basename for i in itertools.count(): - tmp_path = "%s/.%s-%i%s"%(dirname, basename, i, ext) + tmp_path = "%s/%s-%i%s"%(dirname, basename, i, ext) if not cm.exists(tmp_path): break # create tmp copy - allows opening same notebook multiple times self.log.info("Appmode creating tmp copy: "+tmp_path) - cm.copy(path, tmp_path) + await await_if_awaitable(cm.copy(path, tmp_path)) + return tmp_path - return(tmp_path) #=============================================================================== def load_jupyter_server_extension(nbapp): tmpl_dir = os.path.dirname(__file__) + notebook_tmpl_dir = os.path.join(os.path.dirname(notebook.__file__), 'templates') # does not work, because init_webapp() happens before init_server_extensions() - #nbapp.extra_template_paths.append(tmpl_dir) # dows + # nbapp.extra_template_paths.append(tmpl_dir) # dows + + # For jupyter server, the notebook templates are not available in the default search paths. This can be addressed + # by using --ServerApp.extra_template_paths='***site-packages***\notebook\templates', but this is messy. + # To emulate this instead insert the notebook template directory at the start of the searchpath + # These will be used last, so the notebook.html resolves, but the page.html is still from jupyter server templates # For configuration values that can be set server side appmode = Appmode(parent=nbapp) @@ -143,6 +168,7 @@ def load_jupyter_server_extension(nbapp): for loader in getattr(rootloader, 'loaders', [rootloader]): if hasattr(loader, 'searchpath') and tmpl_dir not in loader.searchpath: loader.searchpath.append(tmpl_dir) + loader.searchpath.insert(0, notebook_tmpl_dir) web_app = nbapp.web_app host_pattern = '.*$' diff --git a/setup.py b/setup.py index 45abc63..4624145 100644 --- a/setup.py +++ b/setup.py @@ -26,14 +26,13 @@ def find_version(): packages=["appmode"], include_package_data = True, install_requires=['notebook>=5'], + python_requires='>=3.5', data_files=[('share/jupyter/nbextensions/appmode', [ 'appmode/static/main.js', 'appmode/static/gears.svg' ])], classifiers=[ - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "License :: OSI Approved :: MIT License",