Commit 799ac08a authored by Paolo Tosco's avatar Paolo Tosco
Browse files

- added pythonnotebook directory

- updated CHANGELOG.md
- updated README.md
parent e68a7a7b
......@@ -7,3 +7,7 @@
- Example on how to add context menu items (contextmenus.py)
- Example on how to download and add proteins (download1oit.py)
- Example on how to update ligand properties using a rest service (restexample.py, runrestservice.py)
## [2.0] - 2018-11-28
### Added
- Python Notebook extension (pythonnotebook)
......@@ -8,7 +8,7 @@ The developer example extensions are provided to you on an "AS-IS" basis, howeve
## Installing
See the [installation guide](https://gitlab.com/cresset/flare-python-extensions/blob/master/README.md).
See the [installation guide](https://gitlab.com/cresset/flare-python-extensions/README.md).
## Extensions Descriptions
......@@ -46,6 +46,26 @@ Demonstrates how to use a rest server to add properties to Ligand table when lig
Demonstrates how to create a ribbon tab and group containing various buttons and widgets.
### Python Notebook (pythonnotebook)
Add the Jupyter-based Python Notebook to Flare.
To install, copy the whole `pythonnotebook` directory to your `extensions` directory,
which is in the respective locations on Windows, Linux and macOS:
Windows: `%LOCALAPPDATA%\Cresset BMD\Flare\python\extensions`
Linux: `$HOME/.com.cresset-bmd/Flare/python/extensions`
macOS: `$HOME/Library/Application Support/Cresset BMD/Flare/python/extensions`
Please note that currently the Python Notebook extension is not compatible
with the Python QtConsole extension.
Therefore, if you wish to use the Python Notebook extension make sure that
your `extensions` directory does not contain the `pythonqtconsole` directory.
Conversely, if you prefer to use the Python QtConsole, make sure that your
`extensions` directory does not contain the `pythonnotebook` directory.
To enable the Python Notebook, click on the Python Notebook button
(the one with the Jupyter icon) in the "Code" group of the "Python" ribbon.
## Scripts Descriptions
### Run REST Service (runrestservice.py)
......
# -*- coding: utf-8 -*-
# Copyright (C) 2018 Cresset Biomolecular Discovery Ltd.
# Released under CC-BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/).
# Originally downloaded from https://gitlab.com/cresset
"""Add the Jupyter-based Python Notebook to Flare.
To install, copy the whole "pythonnotebook" directory to your "extensions" directory,
which is in the respective locations on Windows, Linux and macOS:
Windows: %LOCALAPPDATA%\Cresset BMD\Flare\python\extensions
Linux: $HOME/.com.cresset-bmd/Flare/python/extensions
macOS: $HOME/Library/Application Support/Cresset BMD/Flare/python/extensions
Please note that currently the Python Notebook extension is not compatible
with the Python QtConsole extension.
Therefore, if you wish to use the Python Notebook extension make sure that
your "extensions" directory does not contain the "pythonqtconsole" directory.
Conversely, if you prefer to use the Python QtConsole, make sure that your
"extensions" directory does not contain the "pythonnotebook" directory.
To enable the Python Notebook, click on the Python Notebook button
(the one with the Jupyter icon) in the "Code" group of the "Python" ribbon.
"""
import os
from .configurelogging import get_logger
from .common import flare_extension_dir
log = get_logger("FlareJupyter")
# The NotebookPreload.py script is executed before anything else
# This is useful, for example, for thinks that need to be monkey-patched
# before anything else happens
preload_script_path = os.path.join(flare_extension_dir(), "notebookpreload.py")
preload_script = None
try:
with open(preload_script_path, "r") as hnd:
preload_script = hnd.read()
except Exception:
log.warning("Failed to open preload script {0:s} for reading".format(preload_script_path))
if preload_script:
exec(preload_script)
from cresset import flare
from qtconsole.qt import QtCore, QtGui
from .flarenotebook import PythonNotebookWidget
@flare.extension
class PythonNotebookExtension:
"""Flare extension class."""
PYTHON_NOTEBOOK_APPNAME = "Python Notebook"
def load(self):
"""Load in this Flare extension."""
self.init()
print("Loaded {0:s}.{1:s}".format(self.__class__.__module__, self.__class__.__name__))
def py_notebook_button_toggled(self):
"""Call when the Python Notebook ribbon button is toggled."""
if self.py_notebook_button_q_action.isChecked():
self.py_notebook.py_notebook_show()
else:
self.py_notebook.py_notebook_hide()
def init(self):
"""Initialize PythonNotebookExtension."""
QtGui.qApp = QtGui.QApplication
# FIXME: the following line might not be needed any more once
# https://github.com/jupyter/qtconsole/issues/279
# is closed and incorporated in the main qt-console trunk
QtGui.QApplication.instance().font = QtGui.QApplication.font
self.py_notebook = PythonNotebookWidget(None, QtCore.Qt.Window)
self.py_notebook.setWindowTitle(self.PYTHON_NOTEBOOK_APPNAME)
self.py_notebook_button_ui_control = (
flare.main_window()
.ribbon["Python"]["Code"]
.add_button(
self.PYTHON_NOTEBOOK_APPNAME.replace(" ", "\n"), self.py_notebook_button_toggled
)
)
self.py_notebook_button_ui_control.tooltip = "Open the Python Notebook."
self.py_notebook_button_q_action = self.py_notebook_button_ui_control.action()
if not self.py_notebook_button_q_action:
raise RuntimeError("Could not create PythonNotebook button")
icon_file = os.path.join(flare_extension_dir(), "notebook.png")
if os.path.isfile(icon_file):
self.py_notebook_button_ui_control.load_icon(icon_file)
self.py_notebook_button_q_action.setCheckable(True)
self.py_notebook.set_py_notebook_button_action(self.py_notebook_button_q_action)
self.py_notebook_button_q_action.setChecked(False)
self.py_notebook.hide()
# -*- coding: utf-8 -*-
# Copyright (C) 2018 Cresset Biomolecular Discovery Ltd.
# Released under CC-BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/).
# Originally downloaded from https://gitlab.com/cresset
"""Common utility classes."""
import os
import traceback
from qtconsole.qt import QtCore, QtGui
from .configurelogging import get_logger
log = get_logger("common")
def flare_extension_dir():
"""Return the directory we are in, i.e. the Flare Python extensions dir."""
return os.path.dirname(os.path.realpath(__file__))
class DisableWidget:
"""Disable widget on enter; re-enable it on exit."""
def __init__(self, widget):
self._widget = widget
def __enter__(self):
if self._widget:
self._widget.setEnabled(False)
return self
def __exit__(self, exc_type, exc_value, trace_back):
if self._widget:
self._widget.setEnabled(True)
class FileStatMonitor(QtGui.QProgressDialog):
"""Class which monitors change in a file and displays a progressbar."""
MAX_ATTEMPTS = 3
WAIT_FOR_MS = 100
FIRST_TIMEOUT = 1000
SECOND_TIMEOUT = 500
class CannotAccess(Exception):
"""Exception thrown if the file cannot be accessed."""
pass
class NoChange(Exception):
"""Exception thrown if the file has not changed."""
pass
class Empty(Exception):
"""Exception thrown if the file has zero content."""
pass
def _start_monitoring(self):
"""Get the initial stat of the file, then call _init_func if not None."""
self._prev_stat = None
try:
self._prev_stat = os.stat(self._file)
except Exception:
pass
if self._init_func:
self._init_func()
def wait_until_stable(self, timeout=FIRST_TIMEOUT, max_attempts=MAX_ATTEMPTS):
"""Wait until the file stat has stabilized or timeout occurs.
MAX_ATTEMPTS to detect file change are made; if no change takes place,
a NoChange exception is thrown. The other exceptions are thrown upon
first failure.
"""
attempts = 0
max_attempts = self.MAX_ATTEMPTS
success = False
p = 0
maxp = self.FIRST_TIMEOUT + self.SECOND_TIMEOUT
has_changed = False
still_changing = False
now_existing = False
while (not success) and (attempts < max_attempts):
attempts += 1
self._start_monitoring()
t = timeout
with DisableWidget(self._parent):
while t:
v = min(100, int(100.0 * p / maxp))
log.debug("wait_until_stable v = {0:d}".format(v))
if self._parent.isVisible():
self.setValue(v)
QtGui.QApplication.instance().processEvents()
if os.path.isfile(self._file):
if not now_existing:
log.debug("now existing")
t = timeout
now_existing = True
try:
current_stat = os.stat(self._file)
except Exception:
log.warning(
"wait_until_stable exception:\n{0:s}".format(
"".join(traceback.format_exc())
)
)
raise self.CannotAccess
still_changing = (not self._prev_stat) or (
current_stat.st_mtime != self._prev_stat.st_mtime
)
if still_changing:
log.debug("wait_until_stable still_changing")
if not has_changed:
log.debug("wait_until_stable has_changed = True")
has_changed = True
self._prev_stat = current_stat
t = self.SECOND_TIMEOUT
t -= self.WAIT_FOR_MS
p += self.WAIT_FOR_MS / attempts
log.debug("wait_until_stable t = {0:d}".format(t))
QtCore.QThread.msleep(self.WAIT_FOR_MS)
if not self._prev_stat.st_size:
raise self.Empty
success = has_changed and (not still_changing)
if self._parent.isVisible():
self.setValue(100)
self.deleteLater()
if not has_changed:
raise self.NoChange
def __init__(self, file, parent=None, text="Preparing...", init_func=None):
self._file = file
self._parent = parent
self._init_func = init_func
super().__init__(self._parent, QtCore.Qt.Widget)
self.setCancelButton(None)
self.setMinimumDuration(0)
self.setLabelText(text)
self._should_show = self._parent.isVisible()
if self._should_show:
self.forceShow()
else:
self.hide()
self.lower()
# -*- coding: utf-8 -*-
# Copyright (C) 2018 Cresset Biomolecular Discovery Ltd.
# Released under CC-BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/).
# Originally downloaded from https://gitlab.com/cresset
"""Configure logging level and log file location."""
import os
import logging
import tempfile
def get_logger(logger="default"):
"""Return logger appropriate for the logging level."""
is_debug_run = os.environ.get("CRESSET_IPYNB_DEBUG", None)
logging_level = logging.DEBUG if is_debug_run else logging.WARNING
logging.basicConfig(
filename=os.path.join(tempfile.gettempdir(), "flare_ipynb.log"), level=logging_level
)
return logging.getLogger(logger)
# -*- coding: utf-8 -*-
# Copyright (C) 2018 Cresset Biomolecular Discovery Ltd.
# Released under CC-BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/).
# Originally downloaded from https://gitlab.com/cresset
"""Classes to get/set the last visited directory."""
from qtconsole.qt import QtCore
class DirectoryService:
"""Static class to get/set last visited directory."""
@staticmethod
def getLastVisitedDir():
"""Get the last visited directory.
Returns None if none available, or a QDir if available.
"""
settings = QtCore.QSettings()
cwd = settings.value("lastVisitedDir", None)
if cwd:
cwd = QtCore.QDir(cwd)
if (not cwd) or (not cwd.exists()):
documents = QtCore.QStandardPaths.standardLocations(
QtCore.QStandardPaths.DocumentsLocation
)
if documents:
cwd = QtCore.QDir(documents[0])
return cwd
@staticmethod
def _setLastVisitedDirFromQDir(qdir):
"""Set the last visited directory from an argument of type QDir."""
if qdir.exists():
settings = QtCore.QSettings()
settings.setValue("lastVisitedDir", qdir.absolutePath())
@staticmethod
def setLastVisitedDir(arg):
"""Set the last visited directory from an argument of type QDir or QFileInfo."""
if isinstance(arg, QtCore.QDir):
DirectoryService._setLastVisitedDirFromQDir(arg)
elif isinstance(arg, QtCore.QFileInfo):
if arg.isDir():
DirectoryService._setLastVisitedDirFromQDir(arg.absoluteDir())
else:
DirectoryService._setLastVisitedDirFromQDir(arg.dir())
else:
raise TypeError("Expected QDir or QFileInfo, got {0:s}".format(type(arg)))
# -*- coding: utf-8 -*-
# Copyright (C) 2018 Cresset Biomolecular Discovery Ltd.
# Released under CC-BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/).
# Originally downloaded from https://gitlab.com/cresset
"""Classes to manage integration between IPython kernel and Qt event loop."""
import ctypes
from cresset import flare
from qtconsole.qt import QtCore, QtGui
from .configurelogging import get_logger
log = get_logger("eventloop")
class EventLoopHelper(object):
"""Singleton class to process Qt events while Python code is running in the IPython kernel."""
MAX_RESOURCES = 1
_instance = None
def __new__(cls):
"""Call upon class creation."""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.stop_event_loop = True
cls._instance.stop_button_clicked = False
cls._instance.add_run_event_loop_pending_call_thread = None
cls._instance.event_loop_semaphore = QtCore.QSemaphore(EventLoopHelper.MAX_RESOURCES)
cls._instance.stop_event_loop_mutex = QtCore.QMutex(mode=QtCore.QMutex.Recursive)
cls._instance.session_loading = False
return cls._instance
def set_loading_session(self, state):
"""Set a boolean flag if a QtConsole session is being loaded."""
locker = QtCore.QMutexLocker(self.stop_event_loop_mutex)
self.session_loading = state
locker.unlock()
def is_loading_session(self):
"""Return True if a QtConsole session is being loaded."""
locker = QtCore.QMutexLocker(self.stop_event_loop_mutex)
res = self.session_loading
locker.unlock()
return res
def run_event_loop(self, c_ptr):
"""Process Qt events while Python code is running in the PythonNotebook."""
log.debug("1) EventLoopHelper.run_event_loop()")
locker = QtCore.QMutexLocker(self.stop_event_loop_mutex)
log.debug("2) EventLoopHelper.run_event_loop()")
if self.stop_button_clicked or (
flare.main_window() and not flare.main_window().widget().isVisible()
):
log.debug("3) EventLoopHelper.run_event_loop() stop")
self.stop_button_clicked = False
self.session_loading = False
locker.unlock()
self.shutdown_event_loop()
ctypes.pythonapi.PyErr_SetInterrupt()
return 0
if not self.stop_event_loop:
locker.unlock()
log.debug("4) EventLoopHelper.run_event_loop() processEvents()")
QtGui.QApplication.instance().processEvents()
else:
log.debug("5) EventLoopHelper.run_event_loop() stopped running event loop")
self.event_loop_semaphore.release()
return 0
def shutdown_event_loop(self):
"""Stop processing Qt events while Python code is running in the PythonNotebook."""
log.debug("EventLoopHelper.shutdown_event_loop()")
if (
QtGui.QApplication
and QtGui.QApplication.instance()
and self.add_run_event_loop_pending_call_thread
):
log.debug("EventLoopHelper.shutdown_event_loop() acquire mutex")
locker = QtCore.QMutexLocker(self.stop_event_loop_mutex)
if not self.stop_event_loop:
log.debug("EventLoopHelper.shutdown_event_loop() requested stopping event loop")
self.stop_event_loop = True
locker.unlock()
log.debug("1) EventLoopHelper.shutdown_event_loop()")
self.add_run_event_loop_pending_call_thread.wait()
log.debug("2) EventLoopHelper.shutdown_event_loop()")
del self.add_run_event_loop_pending_call_thread
self.add_run_event_loop_pending_call_thread = None
QtGui.QApplication.instance().processEvents()
log.debug("EventLoopHelper.shutdown_event_loop() event loop stopped")
class BlockEventLoop:
"""Context manager class to prevent the Qt event loop from running."""
def __init__(self, kernel_client):
self.kernel_client = kernel_client
def __enter__(self):
self.kernel_client.run_event_loop = False
return self
def __exit__(self, exc_type, exc_value, trace_back):
self.kernel_client.run_event_loop = True
class PythonRunQtEventLoop:
"""Context manager class to run a Qt event loop while Python code is being executed."""
def __init__(self, run_event_loop=True, callback=None):
self.run_event_loop = run_event_loop
self.elh = EventLoopHelper()
self.callback = callback
def __enter__(self):
if (self.run_event_loop):
locker = QtCore.QMutexLocker(self.elh.stop_event_loop_mutex)
if QtGui.QApplication and QtGui.QApplication.instance() and self.elh.stop_event_loop:
log.debug("PythonRunQtEventLoop.__enter__(): started running event loop")
self.elh.stop_event_loop = False
self.elh.add_run_event_loop_pending_call_thread = AddRunEventLoopPendingCall()
self.elh.add_run_event_loop_pending_call_thread.start()
locker.unlock()
if self.callback:
self.callback(True)
return self
def __exit__(self, exc_type, exc_value, trace_back):
log.debug("PythonRunQtEventLoop.__exit__()")
if (self.run_event_loop):
self.elh.shutdown_event_loop()
if self.callback:
self.callback(False)
class AddRunEventLoopPendingCall(QtCore.QThread):
"""QThread class to call Py_AddPendingCall()."""
RunEventLoopCType = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)
runEventLoopCFuncPtr = RunEventLoopCType(EventLoopHelper().run_event_loop)
ShutdownEventLoopCType = ctypes.CFUNCTYPE(None)
shutdownEventLoopCFuncPtr = ShutdownEventLoopCType(EventLoopHelper().shutdown_event_loop)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.elh = EventLoopHelper()
self.setObjectName("PythonNotebookProcessEvents")
def run(self):
"""Call Py_AddPendingCall()."""
if self.elh.event_loop_semaphore.available() != EventLoopHelper.MAX_RESOURCES:
log.warning(
"AddProcessEventsPendingCall.run(): thread started "
"with invalid resource count of {0:d}, expected "
"{1:d}".format(
self.elh.event_loop_semaphore.available(), EventLoopHelper.MAX_RESOURCES
)
)
self.elh.event_loop_semaphore.release(
EventLoopHelper.MAX_RESOURCES - self.elh.event_loop_semaphore.available()
)
self.setPriority(QtCore.QThread.LowestPriority)
while True:
locker = QtCore.QMutexLocker(self.elh.stop_event_loop_mutex)
if self.elh.stop_event_loop:
log.debug("AddProcessEventsPendingCall.run() stop_event_loop is True")
break
locker.unlock()
if self.elh.event_loop_semaphore.tryAcquire(1, 250):
log.debug("AddProcessEventsPendingCall.run() before Py_AddPendingCall")
result = ctypes.pythonapi.Py_AddPendingCall(
AddRunEventLoopPendingCall.runEventLoopCFuncPtr, None
)
log.debug("AddProcessEventsPendingCall.run() after Py_AddPendingCall")
if result != 0:
log.warning(
"AddRunEventLoopPendingCall.run(): "
"failed to add pending call, error code {0:d}".format(result)
)
self.elh.event_loop_semaphore.release()
self.msleep(10)
log.debug("AddProcessEventsPendingCall.run() stopped running event loop")
# -*- coding: utf-8 -*-
# Copyright (C) 2018 Cresset Biomolecular Discovery Ltd.
# Released under CC-BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/).
# Originally downloaded from https://gitlab.com/cresset
"""Custom classes loaded by jupyter-notebook at startup."""
import sys
import os
from notebook.services.kernels.kernelmanager import MappingKernelManager
from notebook.services.contents.largefilemanager import LargeFileManager
from notebook.services.sessions.sessionmanager import SessionManager
from jupyter_client.kernelspec import KernelSpecManager
from tornado import gen
from traitlets import Set, Instance
import nbformat.v4
import_dir = os.path.dirname(os.path.realpath(__file__))
if import_dir not in sys.path:
sys.path.append(import_dir)
from configurelogging import get_logger
class FlareKernelManager(MappingKernelManager):
"""A Kernel manager that connects to an in-process IPython kernel started by Flare."""
@property
def attach_lock(self):
"""Return the abs path to the 'attach.lock' file."""
if not hasattr(self, "_attach_lock"):
self._attach_lock = os.path.join(os.path.dirname(self.connection_dir), "attach.lock")
return self._attach_lock
def _attach_to_flare_kernel(self, kernel_id):
"""Attach to the Flare kernel."""
kernel = self._kernels[kernel_id]
port_names = ("shell_port", "stdin_port", "iopub_port", "hb_port", "control_port")
for port_name in port_names:
setattr(kernel, port_name, 0)
kernel_json = os.path.join(self.connection_dir, "kernel-{0:s}.json".format(self._flare_pid))
kernel.load_connection_file(kernel_json)
def _should_attach_to_flare_kernel(self):
"""Return True is this kernel should attach to the Flare kernel."""
should_attach = os.path.isfile(self.attach_lock)
if should_attach:
os.remove(self.attach_lock)
return should_attach
@gen.coroutine
def start_kernel(self, **kwargs):
"""Start a kernel and attach to the Flare kernel if attach.lock exists."""
should_attach = self._should_attach_to_flare_kernel()
self._log.debug("1) start_kernel() should_attach = {0:s}".format(str(should_attach)))
kernel_id = None
if (not self._kernels) or (not should_attach):
self._log.debug("2) start_kernel()")
kernel_id = super().start_kernel(**kwargs).result()
if should_attach:
self._log.debug("2) should_attach")
self._flare_kernel_id = kernel_id
self._attach_to_flare_kernel(kernel_id)
else:
self._log.debug("3) start_kernel() kernel_id = self._flare_kernel_id")
kernel_id = self._flare_kernel_id
raise gen.Return(kernel_id)
def __init__(self, *args, **kwargs):
self._flare_pid = os.environ["CRESSET_FLARE_PROCESS_PID"]