diff --git a/THANKS b/CONTRIBUTORS.md similarity index 100% rename from THANKS rename to CONTRIBUTORS.md diff --git a/LICENSE b/LICENSE index d3632fc..025ef53 100644 --- a/LICENSE +++ b/LICENSE @@ -1,10 +1,24 @@ -Copyright (c) 2013. See "Contributors". MATLAB (R) is copyright of the Mathworks. +Copyright (c) 2014. See "CONTRIBUTORS.md". +MATLAB (R) is copyright of the Mathworks. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: -- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + -Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pymatbridge/matlab/matlabserver.m b/pymatbridge/matlab/matlabserver.m index 55b223b..91df534 100644 --- a/pymatbridge/matlab/matlabserver.m +++ b/pymatbridge/matlab/matlabserver.m @@ -1,41 +1,95 @@ function matlabserver(socket_address) -% This function takes a socket address as input and initiates a ZMQ session -% over the socket. I then enters the listen-respond mode until it gets an -% "exit" command +% MATLABSERVER Run a Matlab server to handle requests from Python over ZMQ +% +% MATLABSERVER(SOCKET_ADDRESS) initiates a ZMQ session over the provided +% SOCKET_ADDRESS. Once started, it executes client requests and returns +% the response. +% +% The recognized requests are: +% 'ping': Elicit a response from the server +% 'exit': Request the server to shutdown +% 'call': Call a Matlab function with the provdided arguments -json.startup -messenger('init', socket_address); + json.startup + messenger('init', socket_address); + + while true + % don't let any errors escape (and crash the server) + try + msg_in = messenger('listen'); + req = json.load(msg_in); -while(1) - msg_in = messenger('listen'); - req = json.load(msg_in); + switch(req.cmd) + case {'ping'} + messenger('respond', 'pong'); - switch(req.cmd) - case {'connect'} - messenger('respond', 'connected'); + case {'exit'} + messenger('exit'); + clear mex; + break; - case {'exit'} - messenger('exit'); - clear mex; - break; + case {'call'} + resp = call(req); + json_resp = json.dump(resp); + messenger('respond', json_resp); - case {'run_function'} - fhandle = str2func('pymat_feval'); - resp = feval(fhandle, req); - messenger('respond', resp); + otherwise + throw(MException('MATLAB:matlabserver', ['Unrecognized command ' req.cmd])) + end - case {'run_code'} - fhandle = str2func('pymat_eval'); - resp = feval(fhandle, req); - messenger('respond', resp); + catch exception + % format the exception and pass it back to the client + resp.success = false; + resp.result = exception.identifier; + resp.message = exception.message; - case {'get_var'} - fhandle = str2func('pymat_get_variable'); - resp = feval(fhandle, req); - messenger('respond', resp); + json_resp = json.dump(resp); + messenger('respond', json_resp); + end + end +end + + +function resp = call(req) +% CALL Call a Matlab function +% +% RESPONSE = CALL(REQUEST) calls Matlab's FEVAL function, intelligently +% handling the number of input and output arguments so that the argument +% spec is satisfied. +% +% The REQUEST is a struct with three fields: +% 'func': The name of the function to execute +% 'args': A cell array of args to expand into the function arguments +% 'nout': The number of output arguments requested + + % function of no arguments + if ~isfield(req, 'args') + req.args = {} + end + + % determine the number of output arguments + % TODO: What should the default behaviour be? + func = str2func(req.func); + nout = req.nout; + if isempty(nout) + try + nout = min(abs(nargout(func)), 1); + catch + nout = 1; + end + end + % call the function, taking care of broadcasting outputs + switch nout + case 0 + func(req.args{:}); + case 1 + resp.result = func(req.args{:}); otherwise - messenger('respond', 'i dont know what you want'); + [resp.result{1:nout}] = func(req.args{:}); end + % build the response + resp.success = true; + resp.message = 'Successfully completed request'; end diff --git a/pymatbridge/matlab/util/pymat_eval.m b/pymatbridge/matlab/util/pymat_eval.m index b071cb8..8846d82 100644 --- a/pymatbridge/matlab/util/pymat_eval.m +++ b/pymatbridge/matlab/util/pymat_eval.m @@ -6,7 +6,7 @@ % % This allows you to run any matlab code. To be used with webserver.m. % HTTP POST to /web_eval.m with the following parameters: -% code: a string which contains the code to be run in the matlab session +% expr: a string which contains the expression to evaluate in the matlab session % % Should return a json object containing the result % @@ -17,26 +17,26 @@ response.content = ''; -code_check = false; +expr_check = false; if size(field_names) - if isfield(req, 'code') - code_check = true; + if isfield(req, 'expr') + expr_check = true; end end -if ~code_check - response.message = 'No code provided as POST parameter'; +if ~expr_check + response.message = 'No expression provided as POST parameter'; json_response = json.dump(response); return; end -code = req.code; +expr = req.expr; try % tempname is less likely to get bonked by another process. diary_file = [tempname() '_diary.txt']; diary(diary_file); - evalin('base', code); + evalin('base', expr); diary('off'); datadir = fullfile(tempdir(),'MatlabData'); @@ -69,7 +69,7 @@ response.content.stdout = ME.message; end -response.content.code = code; +response.content.expr = expr; json_response = json.dump(response); diff --git a/pymatbridge/matlab/util/pymat_feval.m b/pymatbridge/matlab/util/pymat_feval.m index 2fd3a48..c51362e 100644 --- a/pymatbridge/matlab/util/pymat_feval.m +++ b/pymatbridge/matlab/util/pymat_feval.m @@ -1,7 +1,19 @@ -% Max Jaderberg 2011 - function json_response = matlab_feval(req) + if ~isfield(req, 'func_path') + response.success = false; + response.message = 'No function given as func_path POST parameter'; + json_response = json.dump(response); + end + + if ~isfield(req, 'func_args') + req.func_args = {} + end + + [dir, func_name, ext] = fileparts(req.func_path); + try + response.result = + response.success = 'false'; field_names = fieldnames(req); diff --git a/pymatbridge/matlab/util/pymat_get_variable.m b/pymatbridge/matlab/util/pymat_get_variable.m index c37fa90..b96956c 100644 --- a/pymatbridge/matlab/util/pymat_get_variable.m +++ b/pymatbridge/matlab/util/pymat_get_variable.m @@ -2,31 +2,16 @@ % Reach into the current namespace get a variable in json format that can % be returned as part of a response -response.success = 'false'; - -field_names = fieldnames(req); - -response.content = ''; - -varname_check = false; -if size(field_names) - if isfield(req, 'varname') - varname_check = true; + try + response.result = evalin('base', req.varname); + response.success = true; + catch exception + response.success = false; + response.result = exception.identifier; + response.message = exception.message; end -end -if ~varname_check - response.message = 'No variable name provided as input argument'; json_response = json.dump(response); return -end - - -varname = req.varname; - -response.var = evalin('base', varname); - -json_response = json.dump(response); -return end diff --git a/pymatbridge/publish.py b/pymatbridge/publish.py index 5843795..d18145e 100644 --- a/pymatbridge/publish.py +++ b/pymatbridge/publish.py @@ -132,3 +132,20 @@ def convert_mfile(mfile, outfile=None): nbformat.write(nb, nbfile, format='ipynb') nbfile.close() + +def main(): + import argparse + + parser = argparse.ArgumentParser( + description='Publish a matlab file (.m) as an interactive notebook (.ipynb)') + + parser.add_argument('mfile', action='store', metavar='File', help='Matlab m-file (.m)') + parser.add_argument('--outfile', action='store', metavar='File', + help='Output notebook (.ipynb). Default: same name and location as the input file ', default=None) + + params = parser.parse_args() + + convert_mfile(params.mfile, params.outfile) + +if __name__ == '__main__': + main() diff --git a/pymatbridge/pymatbridge.py b/pymatbridge/pymatbridge.py index 9e9a347..be8d9e0 100644 --- a/pymatbridge/pymatbridge.py +++ b/pymatbridge/pymatbridge.py @@ -1,231 +1,509 @@ -""" -pymatbridge -=========== - -This is a module for communicating and running +"""Main module for running and communicating with Matlab subprocess -Part of Python-MATLAB-bridge, Max Jaderberg 2012 +pymatbridge.py provides Matlab, the main class used to communicate +with the Matlab executable. Each instance starts and manages its +own Matlab subprocess. -This is a modified version using ZMQ, Haoxing Zhang Jan.2014 """ - +from __future__ import print_function +import collections +import functools +import json import numpy as np -import os, time -import zmq -import subprocess +import os import platform +import subprocess import sys +import time +import types +import weakref +import zmq +try: + # Python 2 + basestring + DEVNULL = open(os.devnull, 'w') +except: + # Python 3 + basestring = str + DEVNULL = subprocess.DEVNULL + + +# ---------------------------------------------------------------------------- +# HELPERS +# ---------------------------------------------------------------------------- +def chain(*iterables): + """Yield elements from each iterable in order + + Make an iterator that returns elements from the first iterable until + it is exhausted, then proceeds to the next iterable, until all of the + iterables are exhausted. Unlike itertools.chain, strings are not treated + as iterable, and thus not expanded into characters: + chain([1, 2, 3, 4], 'string') --> 1, 2, 3, 4, 'string' + + Returns: + generator: A generator which yields items from the iterables -import json + """ + for iterable in iterables: + if not isinstance(iterable, collections.Iterable) or isinstance(iterable, basestring): + yield iterable + else: + for item in iterable: + yield item -# JSON encoder extension to handle complex numbers -class ComplexEncoder(json.JSONEncoder): - def default(self, obj): - if isinstance(obj, complex): - return {'real':obj.real, 'imag':obj.imag} - # Handle the default case - return json.JSONEncoder.default(self, obj) -# JSON decoder for complex numbers -def as_complex(dct): - if 'real' in dct and 'imag' in dct: - return complex(dct['real'], dct['imag']) - return dct +class AttributeDict(dict): + """A dictionary with attribute-like access + Values within an AttributeDict can be accessed either via + d[key] or d.key. + See: http://stackoverflow.com/a/14620633 -MATLAB_FOLDER = '%s/matlab' % os.path.realpath(os.path.dirname(__file__)) + """ + def __init__(self, *args, **kwargs): + super(AttributeDict, self).__init__(*args, **kwargs) + self.__dict__ = self -# Start a Matlab server and bind it to a ZMQ socket(TCP/IPC) -def _run_matlab_server(matlab_bin, matlab_socket_addr, matlab_log, matlab_id, matlab_startup_options): - command = matlab_bin - command += ' %s ' % matlab_startup_options - command += ' -r "' - command += "addpath(genpath(" - command += "'%s'" % MATLAB_FOLDER - command += ')), matlabserver(\'%s\'),exit"' % matlab_socket_addr - if matlab_log: - command += ' -logfile ./pymatbridge/logs/matlablog_%s.txt > ./pymatbridge/logs/bashlog_%s.txt' % (matlab_id, matlab_id) +# ---------------------------------------------------------------------------- +# JSON EXTENSION +# ---------------------------------------------------------------------------- +class MatlabEncoder(json.JSONEncoder): + """A JSON extension for encoding numpy arrays to Matlab format - subprocess.Popen(command, shell = True, stdin=subprocess.PIPE) + Numpy arrays are converted to nested lists. Complex numbers + (either standalone or scalars within an array) are converted to JSON + objects. + complex --> {'real': complex.real, 'imag': complex.imag} + """ + def default(self, obj): + if isinstance(obj, np.ndarray): + ordering = range(0, obj.ndim) + ordering[0:2] = ordering[1::-1] + return np.transpose(obj, axes=ordering[::-1]).tolist() + if isinstance(obj, complex): + return {'real':obj.real, 'imag':obj.imag} + # Handle the default case + return super(MatlabEncoder, self).default(obj) - return True +class MatlabDecoder(json.JSONDecoder): + """A JSON extension for decoding Matlab arrays into numpy arrays -class Matlab(object): + The default JSON decoder is called first, then elements within the + resulting object tree are greedily marshalled to numpy arrays. If this + fails, the function recurses into """ - A class for communicating with a matlab session - """ - + def __init__(self, encoding='UTF-8', **kwargs): + # register the complex object decoder with the super class + kwargs['object_hook'] = self.decode_complex + # allowable scalar types we can coerce to (not strings or objects) + super(MatlabDecoder, self).__init__(encoding=encoding, **kwargs) + + def decode(self, s): + # decode the string using the default decoder first + tree = super(MatlabDecoder, self).decode(s) + # recursively attempt to build numpy arrays (top-down) + return self.coerce_to_numpy(tree) + + def decode_complex(self, d): + try: + return complex(d['real'], d['imag']) + except KeyError: + return d + + def coerce_to_numpy(self, tree): + """Greedily attempt to coerce an object into a numeric numpy""" + if isinstance(tree, dict): + return dict((key, self.coerce_to_numpy(val)) for key, val in tree.items()) + if isinstance(tree, list): + array = np.array(tree) + if isinstance(array.dtype.type(), (bool, int, float, complex)): + ordering = range(0, array.ndim) + ordering[-2:] = ordering[:-3:-1] + return np.transpose(array, axes=ordering[::-1]) + else: + return [self.coerce_to_numpy(item) for item in tree] + return tree + + +# ---------------------------------------------------------------------------- +# MATLAB +# ---------------------------------------------------------------------------- +class Matlab(object): def __init__(self, matlab='matlab', socket_addr=None, - id='python-matlab-bridge', log=False, maxtime=60, + id='python-matlab-bridge', log=False, timeout=30, platform=None, startup_options=None): - """ - Initialize this thing. - - Parameters - ---------- - - matlab : str - A string that woul start matlab at the terminal. Per default, this - is set to 'matlab', so that you can alias in your bash setup - - socket_addr : str - A string that represents a valid ZMQ socket address, such as - "ipc:///tmp/pymatbridge", "tcp://127.0.0.1:55555", etc. - - id : str - An identifier for this instance of the pymatbridge - - log : bool - Whether to save a log file in some known location. - - maxtime : float - The maximal time to wait for a response from matlab (optional, - Default is 10 sec) - - platform : string - The OS of the machine on which this is running. Per default this - will be taken from sys.platform. + """Execute functions in a Matlab subprocess via Python + + Matlab provides a pythonic interface for accessing functions in Matlab. + It works by starting a Matlab subprocess and establishing a connection + to it using ZMQ. Function calls are serialized and executed remotely. + + Keyword Args: + matlab (str): A string to the Matlab executable. This defaults + to 'matlab', assuming the executable is on your PATH + socket_addr (str): A string the represents a valid ZMQ socket + address, such as "ipc:///tmp/pymatbridge", "tcp://127.0.0.1:5555" + id (str): An identifier for this instance of the pymatbridge + log (bool): Log status and error messages + timeout: The maximum time to wait for a response from the Matlab + process before timing out (default 30 seconds) + platform (str): The OS of the machine running Matlab. By default + this is determined automatically from sys.platform + startup_options (list): A list of switches that should be passed + to the Matlab subprocess at startup. By default, switches are + passed to disable the graphical session. For a full list of + available switches see: + Windows: http://www.mathworks.com.au/help/matlab/ref/matlabwindows.html + UNIX: http://www.mathworks.com.au/help/matlab/ref/matlabunix.html """ + self.MATLAB_FOLDER = os.path.join(os.path.realpath(os.path.dirname(__file__)), 'matlab') + # Setup internal state variables self.started = False - self.running = False self.matlab = matlab self.socket_addr = socket_addr - self.id = id self.log = log - self.maxtime = maxtime + self.timeout = timeout - if platform is None: - self.platform = sys.platform + # determine the platform-specific options + self.platform = platform if platform else sys.platform + if self.platform == 'win32': + default_socket_addr = "tcp://127.0.0.1:55555" + default_options = ['-automation'] else: - self.platform = platform + default_socket_addr = "ipc:///tmp/pymatbridge" + default_options = ['-nodesktop', '-nosplash'] - if self.socket_addr is None: # use the default - self.socket_addr = "tcp://127.0.0.1:55555" if self.platform == "win32" else "ipc:///tmp/pymatbridge" + self.socket_addr = socket_addr if socket_addr else default_socket_addr + self.startup_options = startup_options if startup_options else default_options - if startup_options: - self.startup_options = startup_options - elif self.platform == 'win32': - self.startup_options = ' -automation -noFigureWindows' - else: - self.startup_options = ' -nodesktop -nodisplay' + # initialize the ZMQ socket + self.context = zmq.Context() + self.socket = self.context.socket(zmq.REQ) + + # auto-generate some useful matlab builtins + self.bind_method('exist', unconditionally=True) + self.bind_method('addpath', unconditionally=True) + self.bind_method('eval', unconditionally=True) + self.bind_method('help', unconditionally=True) + self.bind_method('license', unconditionally=True) + self.bind_method('run', unconditionally=True) + self.bind_method('version', unconditionally=True) + + def __del__(self): + """Forcibly cleanup resources - self.context = None - self.socket = None + The user should always call Matlab.stop to gracefully shutdown the Matlab + process before Matlab leaves scope, but in case they don't, attempt + to cleanup so we don't leave an orphaned process lying around. + + """ + self.stop() - # Start server/client session and make the connection def start(self): - # Start the MATLAB server in a new process - print "Starting MATLAB on ZMQ socket %s" % (self.socket_addr) - print "Send 'exit' command to kill the server" - _run_matlab_server(self.matlab, self.socket_addr, self.log, self.id, self.startup_options) + """Start a new Matlab subprocess and attempt to connect to it via ZMQ + + Raises: + RuntimeError: If Matlab is already running, or failed to start + + """ + if self.started: + raise RuntimeError('Matlab is already running') + + # build the command + command = chain( + self.matlab, + self.startup_options, + '-r', + "\"addpath(genpath('%s')),matlabserver('%s'),exit\"" % (self.MATLAB_FOLDER, self.socket_addr) + ) + + command = ' '.join(command) + print('Starting Matlab subprocess', end='') + self.matlab_process = subprocess.Popen(command, shell=True, stdin=subprocess.PIPE, stdout=DEVNULL) # Start the client - self.context = zmq.Context() self.socket = self.context.socket(zmq.REQ) self.socket.connect(self.socket_addr) - self.started = True # Test if connection is established if (self.is_connected()): - print "MATLAB started and connected!" - return True + print('started') else: - print "MATLAB failed to start" - return False + self.started = False + raise RuntimeError('Matlab failed to start') + def stop(self, timeout=1): + """Stop the Matlab subprocess - # Stop the Matlab server - def stop(self): - req = json.dumps(dict(cmd="exit"), cls=ComplexEncoder) - self.socket.send(req) - resp = self.socket.recv_string() + Attempt to gracefully shutdown the Matlab subprocess. If it fails to + stop within the timeout period, terminate it forcefully. + + Args: + timeout: Time in seconds before SIGKILL is sent + + """ + if not self.started: + return - # Matlab should respond with "exit" if successful - if resp == "exit": - print "MATLAB closed" + req = json.dumps({'cmd': 'exit'}) + try: + # the user might be stopping Matlab because the socket is in a bad state + self.socket.send(req) + except: + pass + start_time = time.time() + while time.time() - start_time < timeout: + if self.matlab_process.poll() is not None: + break + time.sleep(0.1) + else: + self.matlab_process.kill() + + # finalize + self.socket.close() self.started = False - return True - # To test if the client can talk to the server + def restart(self): + """Restart the Matlab subprocess if the state becomes bad + + Aliases the following command: + >> matlab.stop() + >> matlab.start() + + """ + self.stop() + self.start() + def is_connected(self): + """Test if the client can talk to the server + + Raises: + RuntimeError: If there is no running Matlab subprocess to connect to + + Returns: + bool: True if the Matlab subprocess ZMQ server can be contacted, + False, otherwise + + """ if not self.started: - time.sleep(2) - return False + raise RuntimeError('No running Matlab subprocess to connect to') - req = json.dumps(dict(cmd="connect"), cls=ComplexEncoder) + req = json.dumps({'cmd': 'ping'}) self.socket.send(req) start_time = time.time() - while(True): + while time.time() - start_time < self.timeout: + time.sleep(0.5) try: - resp = self.socket.recv_string(flags=zmq.NOBLOCK) - if resp == "connected": - return True - else: - return False + self.socket.recv_string(flags=zmq.NOBLOCK) + return True except zmq.ZMQError: - np.disp(".", linefeed=False) - time.sleep(1) - if (time.time() - start_time > self.maxtime) : - print "Matlab session timed out after %d seconds" % (self.maxtime) - return False + print('.', end='') + sys.stdout.flush() + print('failed to connect to Matlab after %d seconds' % self.timeout) + return False def is_function_processor_working(self): - result = self.run_func('%s/test_functions/test_sum.m' % MATLAB_FOLDER, {'echo': 'Matlab: Function processor is working!'}) - if result['success'] == 'true': + """Check whether the Matlab subprocess can evaluate functions + + First check whether the Python client can talk to the Matlab + server, then if the server is in a state where it can successfully + evaluate a function + + Raises: + RuntimeError: If there is no running Matlab subprocess to connect to + + Returns: + bool: True if Matlab can evaluate a function, False otherwise + + """ + if not self.is_connected(): + return False + + try: + self.abs(2435) return True - else: + except: return False + def execute_in_matlab(self, req): + """Execute a request in the Matlab subprocess - # Run a function in Matlab and return the result - def run_func(self, func_path, func_args=None, maxtime=None): - if self.running: - time.sleep(0.05) + Args: + req (dict): A dictionary containing the request to evaluate in + Matlab. The request should contain the 'cmd' key, and the + corresponding command recognized by matlabserver.m, as well + as any arguments required by that command + + Returns: + resp (dict): A dictionary containing the response from the Matlab + server containing the keys 'success', 'result', and 'message' - req = dict(cmd="run_function") - req['func_path'] = func_path - req['func_args'] = func_args + Raises: + RuntimeError: If the 'success' field of the resp object is False, + then an exception is raised with the value of the 'result' + field which will contain the identifier of the exception + raised in Matlab, and the 'message' field, which will contain + the reason for the exception - req = json.dumps(req, cls=ComplexEncoder) + """ + # send the request + req = json.dumps(req, cls=MatlabEncoder) self.socket.send(req) - resp = self.socket.recv_string() - resp = json.loads(resp, object_hook=as_complex) + # receive the response + resp = self.socket.recv_string() + resp = AttributeDict(json.loads(resp, cls=MatlabDecoder)) + if not resp.success: + raise RuntimeError(resp.result +': '+ resp.message) return resp + + def __getattr__(self, name): + return self.bind_method(name) + def bind_method(self, name, unconditionally=False): + """Generate a Matlab function and bind it to the instance + + This is where the magic happens. When an unknown attribute of the + Matlab class is requested, it is assumed to be a call to a + Matlab function, and is generated and bound to the instance. + + This works because getattr() falls back to __getattr__ only if no + attributes of the requested name can be found through normal + routes (__getattribute__, __dict__, class tree). + + bind_method first checks whether the requested name is a callable + Matlab function before generating a binding. + + Args: + name (str): The name of the Matlab function to call + e.g. 'sqrt', 'sum', 'svd', etc + unconditionally (bool): Bind the method without performing + checks. Used to bootstrap methods that are required and + know to exist + + Returns: + Method: a reference to a newly bound Method instance if the + requested name is determined to be a callable function + + Raises: + AttributeError: if the requested name is not a callable + Matlab function - # Run some code in Matlab command line provide by a string - def run_code(self, code, maxtime=None): - if self.running: - time.sleep(0.05) + """ + # TODO: This does not work if the function is a mex function inside a folder of the same name + if not unconditionally and not self.exist(name): + raise AttributeError("'Matlab' object has no attribute '%s'" % name) - req = dict(cmd="run_code") - req['code'] = code - req = json.dumps(req, cls=ComplexEncoder) - self.socket.send(req) - resp = self.socket.recv_string() - resp = json.loads(resp, object_hook=as_complex) + # create a new method instance + method_instance = Method(weakref.ref(self), name) + method_instance.__name__ = name - return resp + # bind to the Matlab instance with a weakref (to avoid circular references) + setattr(self, name, types.MethodType(method_instance, weakref.ref(self), Matlab)) + return getattr(self, name) + + + def run_func(self, func_path, func_args=None, maxtime=None): + path, filename = os.path.split(func_path) + func, ext = filename.split('.') + self.addpath(path) + + def run_code(self, code, maxtime=None): + try: + return {'result': self.eval(code), 'success': 'true', 'message': ''} + except RuntimeError as e: + return {'result': '', 'success': 'false', 'message': e} + def get_variable(self, varname, maxtime=None): - if self.running: - time.sleep(0.05) + return self.evalin('base',varname) - req = dict(cmd="get_var") - req['varname'] = varname - req = json.dumps(req, cls=ComplexEncoder) - self.socket.send(req) - resp = self.socket.recv_string() - resp = json.loads(resp, object_hook=as_complex) - return resp['var'] +# ---------------------------------------------------------------------------- +# MATLAB METHOD +# ---------------------------------------------------------------------------- +class Method(object): + + def __init__(self, parent, name): + """An object representing a Matlab function + + Methods are dynamically bound to instances of Matlab objects and + represent a callable function in the Matlab subprocess. + + Args: + parent: A reference to the parent (Matlab instance) to which the + Method is being bound + name: The name of the Matlab function this represents + + """ + self.name = name + self._parent = parent + self.doc = None + + def __call__(self, unused_parent_weakref, *args, **kwargs): + """Call a function with the supplied arguments in the Matlab subprocess + + Args: + The *args parameter is unpacked and forwarded verbatim to Matlab. + It contains arguments in the order that they would appear in a + native function call. + Keyword Args: + Keyword arguments are passed to Matlab in the form [key, val] so + that matlab.plot(x, y, '--', LineWidth=2) would be translated into + plot(x, y, '--', 'LineWidth', 2) + + nout (int): The number of arguments to output. By default this is + 1 for functions that return 1 or more values, and 0 for + functions that return no values. This is useful for functions + that change their behvaiour depending on the number of inputs: + U, S, V = matlab.svd(A, nout=3) + + """ + # parse out number of output arguments + nout = kwargs.pop('nout', None) + + # convert keyword arguments to arguments + args += tuple(item for pair in zip(kwargs.keys(), kwargs.values()) for item in pair) + + # build request + req = {'cmd': 'call', 'func': self.name, 'args': args, 'nout': nout} + resp = self.parent.execute_in_matlab(req) + + # return the result + return resp.get('result', None) + + @property + def parent(self): + """Get the actual parent from the stored weakref + + The parent (Matlab instance) is stored as a weak reference + to eliminate circular references from dynamically binding Methods + to Matlab. + + """ + parent = self._parent() + if parent is None: + raise AttributeError('Stale reference to attribute of non-existent Matlab object') + return parent + + @property + def __doc__(self): + """Fetch the docstring from Matlab + + Get the documentation for a Matlab function by calling Matlab's builtin + help() then returning it as the Python docstring. The result is cached + so Matlab is only ever polled on the first request + + """ + if self.doc is None: + self.doc = self.parent.help(self.name) + return self.doc diff --git a/pymatbridge/version.py b/pymatbridge/version.py deleted file mode 100644 index eb7400d..0000000 --- a/pymatbridge/version.py +++ /dev/null @@ -1,99 +0,0 @@ -"""pymatbridge version/release information""" - -# Format expected by setup.py and doc/source/conf.py: string of form "X.Y.Z" -_version_major = 0 -_version_minor = 3 -_version_micro = '' # use '' for first of series, number for 1 and above -_version_extra = 'dev' -#_version_extra = '' # Uncomment this for full releases - -# Construct full version string from these. -_ver = [_version_major, _version_minor] -if _version_micro: - _ver.append(_version_micro) -if _version_extra: - _ver.append(_version_extra) - -__version__ = '.'.join(map(str, _ver)) - -CLASSIFIERS = ["Development Status :: 3 - Alpha", - "Environment :: Console", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Topic :: Scientific/Engineering"] - -description = "pymatbridge is a set of python and matlab functions to allow these two systems to talk to each other" - -long_description = """ - -Pymatbridge -=========== - -This package provides a set of python and matlab functions with the goal of -providing an easy, seamless way to call Matlab functions within Python (not so -much the other way, because why would anyone want to do something like that?). - -TODO: more documentation will be here at some point. - -License information -=================== - -Copyright (c) 2012 -- , Max Jaderberg, Ariel Rokem, Haoxing Zhao -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this -list of conditions and the following disclaimer in the documentation and/or -other materials provided with the distribution. - -Neither the name of the Oxford University, Stanford University nor the names of -its contributors may be used to endorse or promote products derived from this -software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS' AND - ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE - FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -""" - -NAME = "pymatbridge" -MAINTAINER = "Ariel Rokem" -MAINTAINER_EMAIL = "arokem@gmail.com" -DESCRIPTION = description -LONG_DESCRIPTION = long_description -URL = "https://github.com/arokem/python-matlab-bridge" -DOWNLOAD_URL = "https://github.com/arokem/python-matlab-bridge/archive/master.tar.gz" -LICENSE = "BSD" -AUTHOR = "https://github.com/arokem/python-matlab-bridge/contributors" -AUTHOR_EMAIL = "arokem@gmail.com" -PLATFORMS = "OS Independent" -MAJOR = _version_major -MINOR = _version_minor -MICRO = _version_micro -VERSION = __version__ -PACKAGES = ['pymatbridge'] -PACKAGE_DATA = {"pymatbridge": ["matlab/matlabserver.m", "matlab/messenger.*", - "matlab/usrprog/*", "matlab/util/*.m", - "matlab/util/json_v0.2.2/LICENSE", - "matlab/util/json_v0.2.2/README.md", - "matlab/util/json_v0.2.2/test/*", - "matlab/util/json_v0.2.2/+json/*.m", - "matlab/util/json_v0.2.2/+json/java/*", - "tests/*.py", "examples/*.ipynb"]} - -REQUIRES = [] -BIN=['scripts/publish-notebook'] diff --git a/scripts/publish-notebook b/scripts/publish-notebook deleted file mode 100755 index 14e57c5..0000000 --- a/scripts/publish-notebook +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python -import argparse as arg -import pymatbridge.publish as publish - -parser = arg.ArgumentParser(description='Publish a matlab file (.m) as an interactive notebook (.ipynb)') - -parser.add_argument('mfile', action='store', metavar='File', - help='Matlab m-file (.m)') - -parser.add_argument('--outfile', action='store', metavar='File', - help='Output notebook (.ipynb). Default: same name and location as the input file ', default=None) - -params = parser.parse_args() - - -if __name__ == "__main__": - publish.convert_mfile(params.mfile, params.outfile) diff --git a/setup.py b/setup.py index e1fb31f..c45be2b 100755 --- a/setup.py +++ b/setup.py @@ -1,67 +1,91 @@ #!/usr/bin/env python -"""Setup file for python-matlab-bridge""" - import os import sys +import glob import shutil +from setuptools import setup -# BEFORE importing distutils, remove MANIFEST. distutils doesn't properly -# update it when the contents of directories change. -if os.path.exists('MANIFEST'): - os.remove('MANIFEST') - -from distutils.core import setup - -# Find the messenger binary file and copy it to /matlab folder. - -def copy_bin(bin_path): - if os.path.exists(bin_path): - shutil.copy(bin_path, "./pymatbridge/matlab") - return True - else: - return False -if sys.platform == "darwin": - if not copy_bin("./messenger/mexmaci64/messenger.mexmaci64"): - raise ValueError("messenger.mexmaci64 is not built yet. Please build it yourself.") +# ---------------------------------------------------------------------------- +# HELPERS +# ---------------------------------------------------------------------------- +def read(file_name): + with open(os.path.join(os.path.dirname(__file__), file_name)) as f: + return f.read() -elif sys.platform == "linux2": - if not copy_bin("./messenger/mexa64/messenger.mexa64"): - raise ValueError("messenger.mexa64 is not built yet. Please build it yourself.") -elif sys.platform == "win32": - t1 = copy_bin("./messenger/mexw64/messenger.mexw64") - t2 = copy_bin("./messenger/mexw32/messenger.mexw32") - if not (t1 or t2): - raise ValueError("Neither messenger.mexw32 or mex264 is built yet. Please build the appropriate one yourself") +# ---------------------------------------------------------------------------- +# MEX MESSENGER +# ---------------------------------------------------------------------------- +messengers = { + 'darwin': ['messenger/mexmaci64/messenger.mexmaci64'], + 'linux2': ['messenger/mexa64/messenger.mexa64'], + 'win32': ['messenger/mexw64/messenger.mexw64', + 'messenger/mexw32/messenger.mexw32'] +}.get(sys.platform, []) -else: - raise ValueError("Known platform") +for messenger in messengers: + try: + shutil.copy(messenger, 'pymatbridge/matlab') + except IOError: + pass -# Get version and release info, which is all stored in pymatbridge/version.py -ver_file = os.path.join('pymatbridge', 'version.py') -exec(open(ver_file).read()) +if not glob.glob('pymatbridge/matlab/messenger.mex*'): + raise IOError('The Matlab messenger mex script is not yet built for your system. ' + 'Please build messenger/src/messenger.c manually and copy the ' + 'binary to pymatbridge/matlab before continuing with setup.') -opts = dict(name=NAME, - maintainer=MAINTAINER, - maintainer_email=MAINTAINER_EMAIL, - description=DESCRIPTION, - long_description=LONG_DESCRIPTION, - url=URL, - download_url=DOWNLOAD_URL, - license=LICENSE, - classifiers=CLASSIFIERS, - author=AUTHOR, - author_email=AUTHOR_EMAIL, - platforms=PLATFORMS, - version=VERSION, - packages=PACKAGES, - package_data=PACKAGE_DATA, - requires=REQUIRES, - scripts=BIN - ) +# ---------------------------------------------------------------------------- +# SETUP +# ---------------------------------------------------------------------------- +__version__ = '0.4.dev' -# Now call the actual setup function -if __name__ == '__main__': - setup(**opts) +setup( + name = 'pymatbridge', + version = __version__, + platforms = 'OS Independent', + description = 'A package to call Matlab functions from Python', + long_description = read('README.md'), + maintainer = 'Ariel Rokem', + maintainer_email = 'arokem@gmail.com', + url = 'https://github.com/arokem/python-matlab-bridge', + download_url = 'https://github.com/arokem/python-matlab-bridge/archive/master.tar.gz', + license = 'BSD', + packages = [ + 'pymatbridge' + ], + install_requires = [ + 'numpy>=1.7.0', + 'pyzmq>=13.0.0' + ], + entry_points = { + 'console_scripts': [ + 'publish-notebook = pymatbridge.publish:main' + ] + }, + package_data = { + 'pymatbridge': [ + 'matlab/matlabserver.m', + 'matlab/messenger.*', + 'matlab/usrprog/*', + 'matlab/util/*.m', + 'matlab/util/json_v0.2.2/LICENSE', + 'matlab/util/json_v0.2.2/README.md', + 'matlab/util/json_v0.2.2/test/*', + 'matlab/util/json_v0.2.2/+json/*.m', + 'matlab/util/json_v0.2.2/+json/java/*', + 'tests/*.py', + 'examples/*.ipynb' + ] + }, + classifiers = [ + 'Development Status :: 3 - Alpha', + 'Environment :: Console', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Scientific/Engineering' + ] +)