diff --git a/CHANGELOG.md b/CHANGELOG.md index c23c397..b05afb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [v0.1.4](https://github.com/pace-neutrons/libpymcr/compare/v0.1.3...v0.1.4) + +## Add IPython magics + +Add IPython magics from pace-python (to allow in-line text output and figures in Jupyter notebooks). + + # [v0.1.3](https://github.com/pace-neutrons/libpymcr/compare/v0.1.2...v0.1.3) ## Bugfixes for PySpinW diff --git a/CITATION.cff b/CITATION.cff index cc6f4e7..c3e29b8 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -15,7 +15,7 @@ authors: given-names: "Gregory S." orcid: https://orcid.org/0000-0002-2787-8054 title: "libpymcr" -version: "0.1.3" +version: "0.1.4" date-released: "2023-03-24" license: "GPL-3.0-only" repository: "https://github.com/pace-neutrons/libpymcr" diff --git a/libpymcr/IPythonMagics.py b/libpymcr/IPythonMagics.py new file mode 100644 index 0000000..33da3e6 --- /dev/null +++ b/libpymcr/IPythonMagics.py @@ -0,0 +1,261 @@ +from IPython import get_ipython +from IPython.core import magic_arguments +from IPython.core.magic import Magics, magics_class, line_magic +from IPython.display import Image, display +import ipykernel + +import threading +import ctypes +import time +import sys +import io +import os +try: + from tempfile import TemporaryDirectory, TemporaryFile, NamedTemporaryFile +except ImportError: + from backports.tempfile import TemporaryDirectory, TemporaryFile, NamedTemporaryFile + +_magic_class_ref = None + +# We will overload the `post_run_cell` event with this function +# That callback is a method of `EventManager` hence the `self` argument +def showPlot(self=None, result=None): + # We use a global reference to the magics class to get the reference to Matlab interpreter + # If it doesn't exist, we can't do anything, and assume the user is just using Python + ip = get_ipython() + if ip is None or _magic_class_ref is None or _magic_class_ref.plot_type != 'inline': + return + if _magic_class_ref.m is None: + try: + from . import Matlab + _magic_class_ref.m = Matlab()._interface + except RuntimeError: + return + interface = _magic_class_ref.m + nfig = int(interface.call('numel', interface.call('get', 0, "children"))[0][0]) + if nfig == 0: + return + if _magic_class_ref.next_pars: + width, height, resolution = (_magic_class_ref.next_pars[idx] for idx in ['width', 'height', 'resolution']) + else: + width, height, resolution = (_magic_class_ref.width, _magic_class_ref.height, _magic_class_ref.resolution) + format = 'png' + with TemporaryDirectory() as tmpdir: + try: + interface.call('eval', + "arrayfun(@(h) set(h, 'position', [0, 0, {}, {}]), get(0, 'children')')" + .format(width, height), nargout=0) + interface.call('eval', + "arrayfun(@(h, i) print(h, sprintf('{}/%i', i), '-d{}', '-r{}'),get(0, 'children'), (1:{})')" + .format('/'.join(tmpdir.split(os.sep)), format, resolution, nfig), + nargout=0) + interface.call('eval', "arrayfun(@(h) close(h), get(0, 'children'))", nargout=0) + for fname in sorted(os.listdir(tmpdir)): + display(Image(filename=os.path.join(tmpdir, fname))) + except Exception as exc: + ip.showtraceback() + return + finally: + interface.call('set', 0, 'defaultfigurevisible', 'off', nargout=0) + if _magic_class_ref.next_pars: + _magic_class_ref.next_pars = None + +# Matlab writes to the C-level stdout / stderr file descriptors +# whereas IPython overloads the Python-level sys.stdout / sys.stderr streams +# To force Matlab output into the IPython cells we need to +# 1. Duplicate the stdout/err file descriptors into a pipe (with os.dup2) +# 2. Create a thread which watches the pipe and re-prints to IPython +# See: https://stackoverflow.com/questions/41216215/ +# https://eli.thegreenplace.net/2015/redirecting-all-kinds-of-stdout-in-python/ + +class Redirection(object): + # Class which redirects a C-level file descriptor to the equiv. IPython stream + + thread = None + stop_flag = None + saved_fd = None + read_pipe = None + exc_info = None + + def __init__(self, target='stdout'): + self.target = {'stdout':sys.__stdout__, 'stderr':sys.__stderr__}[target].fileno() + self.output = {'stdout':sys.stdout, 'stderr':sys.stderr}[target] + self.ip = get_ipython() + self.flush = lambda: None + + def not_redirecting(self): + return ( + self.ip is None or _magic_class_ref is None or + (_magic_class_ref.output != 'inline' and self.saved_fd == None) + ) + + def pre(self): + if self.not_redirecting(): + return + if self.saved_fd == None: + self.saved_fd = os.dup(self.target) + self.read_pipe, write_pipe = os.pipe() + os.dup2(write_pipe, self.target) + os.close(write_pipe) + + def redirect_thread(): + try: + while not self.stop_flag: + raw = os.read(self.read_pipe, 1000) + if raw: + self.output.write(raw.decode()) + self.flush() + except Exception: + self.exc_info = sys.exc_info() + + self.stop_flag = False + self.thread = threading.Thread(target=redirect_thread) + self.thread.daemon = True # Makes the thread non-blocking + self.thread.start() + + def showtraceback(self): + self.ip.showtraceback() + + def post(self): + if self.not_redirecting() or self.saved_fd == None: + return + sys.stdout.flush() + os.dup2(self.saved_fd, self.target) + self.stop_flag = True + os.close(self.read_pipe) + os.close(self.saved_fd) + if sys.platform.startswith("linux") or sys.platform.startswith("darwin"): + self.thread.join() + self.thread = None + self.saved_fd = None + if self.exc_info: + self.showtraceback() + + +@magics_class +class MatlabMagics(Magics): + """ + Class for IPython magics for interacting with Matlab + + It defines several magic functions: + + %matlab_plot_mode - sets up the plotting environment (default 'inline') + %matlab_fig - defines the inline figure size and resolution for the next plot only + """ + + def __init__(self, shell, interface): + super(MatlabMagics, self).__init__(shell) + self.m = interface + self.shell = get_ipython().__class__.__name__ + self.output = 'inline' + self.plot_type = 'inline' if self.shell == 'ZMQInteractiveShell' \ + else 'windowed' + self.width = 400 + self.height = 300 + self.resolution = 100 + self.next_pars = None + global _magic_class_ref + _magic_class_ref = self + + @line_magic + @magic_arguments.magic_arguments() + @magic_arguments.argument('plot_type', type=str, help="Matlab plot type, either: 'inline' or 'windowed'") + @magic_arguments.argument('output', nargs='?', type=str, help="Matlab output, either: 'inline' or 'console'") + @magic_arguments.argument('-w', '--width', type=int, help="Default figure width in pixels [def: 400]") + @magic_arguments.argument('-h', '--height', type=int, help="Default figure height in pixels [def: 300]") + @magic_arguments.argument('-r', '--resolution', type=int, help="Default figure resolution in dpi [def: 100]") + def matlab_plot_mode(self, line): + """Set up libpymcr to work with IPython notebooks + + Use this magic function to set the behaviour of Matlab programs Horace and SpinW in Python. + You can specify how plots should appear: either 'inline' [default] or 'windowed'. + You can also specify how Matlab text output from functions appear: 'inline' [default] or 'console' + + Examples + -------- + By default the inline backend is used for both figures and outputs. + To switch behaviour use, use: + + In [1]: %matlab_plot_mode windowed # windowed figures, output unchanged ('inline' default) + In [2]: %matlab_plot_mode console # figure unchanged ('inline' default), console output + In [3]: %matlab_plot_mode windowed console # windowed figures, console output + In [4]: %matlab_plot_mode inline inline # inline figures, inline output + In [5]: %matlab_plot_mode inline # inline figures, inline output + In [6]: %matlab_plot_mode inline console # inline figures, console output + In [7]: %matlab_plot_mode windowed inline # windowed figures, console output + + Note that if you specify `%matlab_plot_mode inline` this sets `'inline'` for _both_ figures and outputs. + If you want inline figures and console outputs or windowed figures and inline output you must specify + that specifically. + + Note that using (default) inline text output imposes a slight performance penalty. + + For inlined figures, you can also set the default figure size and resolution with + + In [8]: %matlab_plot_mode inline --width 400 --height 300 --resolution 150 + + The values are in pixels for the width and height and dpi for resolution. A short cut: + + In [9]: %matlab_plot_mode inline -w 400 -h 300 -r 150 + + Also works. The width, height and resolution only applies to inline figures. + You should use the usual Matlab commands to resize windowed figures. + """ + args = magic_arguments.parse_argstring(self.matlab_plot_mode, line) + plot_type = args.plot_type if args.plot_type else self.plot_type + output = args.output if args.output else self.output + if args.plot_type and args.plot_type == 'inline' and args.output == None: + output = 'inline' + self.output = output + if plot_type == 'inline': + self.plot_type = plot_type + if args.width: self.width = args.width + if args.height: self.height = args.height + if args.resolution: self.resolution = args.resolution + elif plot_type == 'windowed': + self.plot_type = plot_type + else: + raise RuntimeError(f'Unknown plot type {plot_type}') + if self.m is None: + try: + from . import Matlab + self.m = Matlab()._interface + except RuntimeError: + return + if plot_type == 'inline': + self.m.call('set', 0, 'defaultfigurevisible', 'off', nargout=0) + self.m.call('set', 0, 'defaultfigurepaperpositionmode', 'manual', nargout=0) + elif plot_type == 'windowed': + self.m.call('set', 0, 'defaultfigurevisible', 'on', nargout=0) + self.m.call('set', 0, 'defaultfigurepaperpositionmode', 'auto', nargout=0) + else: + raise RuntimeError(f'Unknown plot type {plot_type}') + + @line_magic + @magic_arguments.magic_arguments() + @magic_arguments.argument('-w', '--width', type=int, help="Default figure width in pixels [def: 400]") + @magic_arguments.argument('-h', '--height', type=int, help="Default figure height in pixels [def: 300]") + @magic_arguments.argument('-r', '--resolution', type=int, help="Default figure resolution in dpi [def: 100]") + def matlab_fig(self, line): + """Defines size and resolution of the next inline Matlab figure to be plotted + + Use this magic function to define the figure size and resolution of the next figure + (and only that figure) without changing the default size and resolution. + + Examples + -------- + Size and resolution is specified as options, any which is not defined here will use the default values + These values are reset after the figure is plotted (default: width=400, height=300, resolution=100) + + In [1]: %matlab_fig -w 800 -h 200 -r 300 + m.plot(-pi:0.01:pi, sin(-pi:0.01:pi), '-') + + In [2]: m.plot(-pi:0.01:pi, cos(-pi:0.01:pi), '-') + + The sine graph in the first cell will be 800x200 at 300 dpi, whilst the cosine graph is 400x300 150 dpi. + """ + args = magic_arguments.parse_argstring(self.matlab_fig, line) + width = args.width if args.width else self.width + height = args.height if args.height else self.height + resolution = args.resolution if args.resolution else self.resolution + self.next_pars = {'width':width, 'height':height, 'resolution':resolution} diff --git a/libpymcr/Matlab.py b/libpymcr/Matlab.py index 64ad4f2..e8ba6e5 100644 --- a/libpymcr/Matlab.py +++ b/libpymcr/Matlab.py @@ -9,6 +9,8 @@ # When the global ref is deleted (e.g. when Python exits) the __del__ method is called # Which then gracefully shutsdown Matlab, else we get a segfault. _global_matlab_ref = None +_has_registered_magic = None + class _MatlabInstance(object): def __init__(self, ctffile, matlab_dir=None, options=None): @@ -63,7 +65,7 @@ def getdoc(self): class Matlab(object): - def __init__(self, ctffile, mlPath=None): + def __init__(self, ctffile=None, mlPath=None): """ Create an interface to a matlab compiled python library and treat the objects in a python/matlab way instead of the ugly way it is done by default. @@ -74,6 +76,8 @@ def __init__(self, ctffile, mlPath=None): global _global_matlab_ref if _global_matlab_ref is None: + if ctffile is None: + raise RuntimeError('Matlab is not initialised, please provide a CTF path') _global_matlab_ref = _MatlabInstance(ctffile, mlPath) self._interface = _global_matlab_ref.interface @@ -114,3 +118,36 @@ def _get_xml_tag(lis, tag_start, tag_end): except ValueError: outputs = [] print('[{}] = {}({})'.format(','.join(outputs), ml_name, ','.join(inputs))) + + +def register_ipython_magics(): + try: + import IPython + except ImportError: + return None + else: + running_kernel = IPython.get_ipython() + # Only register these magics when running in a notebook / lab + # Other values seen are: 'TerminalInteractiveShell' and 'InteractiveShellEmbed' + if (running_kernel.__class__.__name__ != 'ZMQInteractiveShell' + and running_kernel.__class__.__name__ != 'SpyderShell'): + return None + global _has_registered_magic + _has_registered_magic = True + if running_kernel is None or sys.__stdout__ is None or sys.__stderr__ is None: + return None + from . import IPythonMagics + from traitlets import Instance + shell = Instance('IPython.core.interactiveshell.InteractiveShellABC', allow_none=True) + magics = IPythonMagics.MatlabMagics(shell, None) + running_kernel.register_magics(magics) + running_kernel.events.register('post_run_cell', IPythonMagics.showPlot) + # Only do redirection for Jupyter notebooks - causes errors on Spyder + if running_kernel == 'ZMQInteractiveShell': + redirect_stdout = IPythonMagics.Redirection(target='stdout') + running_kernel.events.register('pre_run_cell', redirect_stdout.pre) + running_kernel.events.register('post_run_cell', redirect_stdout.post) + + +if not _has_registered_magic: + register_ipython_magics()