diff --git a/pymatbridge/examples/example_func.m b/pymatbridge/examples/example_func.m new file mode 100644 index 0000000..063fbaf --- /dev/null +++ b/pymatbridge/examples/example_func.m @@ -0,0 +1,3 @@ +function lol = bar_func(name) + lol=['hello from ' name]; +end diff --git a/pymatbridge/examples/example_script.m b/pymatbridge/examples/example_script.m new file mode 100644 index 0000000..ae468e9 --- /dev/null +++ b/pymatbridge/examples/example_script.m @@ -0,0 +1,3 @@ +q=1:10; +f=45; + diff --git a/pymatbridge/examples/outvariables.py b/pymatbridge/examples/outvariables.py new file mode 100644 index 0000000..4502753 --- /dev/null +++ b/pymatbridge/examples/outvariables.py @@ -0,0 +1,57 @@ +import time +import numpy as np +import numpy.testing as npt +import os.path +import pymatbridge + +_dir = os.path.dirname(os.path.abspath(__file__)) + +mlab = pymatbridge.Matlab(matlab='/opt/matlab/R2013a/bin/matlab', log=True, capture_stdout=True) +mlab.start() + +if 1: + #conventional behaviour, perform the matlab command and return the result + z = mlab.zeros(5) + npt.assert_equal(z, np.zeros((5,5))) + + #perform the same command, and save the 1 output variable on the matlab side + #with the name 'z'. return a placeholder containing some metadata about it + placeholder = mlab.zeros(5,nout=1,saveout=('z',)) + assert placeholder == '__VAR=z|double([5 5])' + #now return the result + z = mlab.get_variable('z') + npt.assert_equal(z, np.zeros((5,5))) + +if 1: + #this time the matlab command returns two variables + x,y = mlab.meshgrid(range(1,4),range(10,15),nout=2) + npx,npy = np.meshgrid(range(1,4),range(10,15)) + npt.assert_equal(x,npx); npt.assert_equal(y,npy) + + #perform the same command, but leave the result in matlab + placeholder = mlab.meshgrid(range(1,4),range(10,15),nout=2,saveout=('X','Y')) + assert placeholder == '__VAR=Y|double([5 3]);__VAR=X|double([5 3]);' + + #now return the result + x = mlab.get_variable('X') + npt.assert_equal(x,npx) + +if 1: + mlab.run_func(os.path.join(_dir,'example_func.m'), 'john', nout=1, saveout=('lol',)) + assert 'hello from john' == mlab.get_variable('lol') + +if 1: + mlab.run_script(os.path.join(_dir,'example_script.m')) + q = mlab.get_variable('q') + npt.assert_equal(q,range(1,11)) + f = mlab.get_variable('f') + npt.assert_equal(f,45) + +if 1: + mlab.run_code('foo=1:100;') + m = mlab.get_variable('foo') + npt.assert_equal(m,range(1,101)) + + mlab.run_code('foo=1:100;') + +mlab.stop() diff --git a/pymatbridge/matlab/matlabserver.m b/pymatbridge/matlab/matlabserver.m index 55b223b..066e185 100644 --- a/pymatbridge/matlab/matlabserver.m +++ b/pymatbridge/matlab/matlabserver.m @@ -1,41 +1,123 @@ 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(1) - msg_in = messenger('listen'); - req = json.load(msg_in); + while true + % don't let any errors escape (and crash the server) + try + msg_in = messenger('listen'); + req = json.load(msg_in); - switch(req.cmd) - case {'connect'} - messenger('respond', 'connected'); + switch(req.cmd) + case {'ping'} + messenger('respond', 'pong'); - case {'exit'} - messenger('exit'); - clear mex; - break; + case {'exit'} + messenger('exit'); + clear mex; + break; - case {'run_function'} - fhandle = str2func('pymat_feval'); - resp = feval(fhandle, req); - messenger('respond', resp); + case {'call'} + resp = call(req); + json_resp = json.dump(resp); + messenger('respond', json_resp); - case {'run_code'} - fhandle = str2func('pymat_eval'); - resp = feval(fhandle, req); - messenger('respond', resp); + otherwise + throw(MException('MATLAB:matlabserver', ['Unrecognized command ' req.cmd])) + end - case {'get_var'} - fhandle = str2func('pymat_get_variable'); - 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 = getReport(exception) + 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 + + args = {}; + if req.nin > 0 + for i=1:req.nin + args{i} = req.args.(['a' num2str(i-1)]); + end + end + + % determine the number of output arguments + % TODO: What should the default behaviour be? + func = str2func(req.func); + nout = req.nout; + [saveout nsaveout] = regexp(req.saveout, '(?:;)+', 'split', 'match'); + 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(args{:}); + case 1 + resp.result = func(args{:}); otherwise - messenger('respond', 'i dont know what you want'); + [resp.result{1:nout}] = func(args{:}); + if ~length(nsaveout) + %because of ambiguity of json encoding arrays of matrices + %convert multiple output arguments into a structure with + %fields name a0..aN. Convert these back to a list of matrices + %at the python end + result_struct = struct('nout',nout); + for i=1:nout + result_struct.(['a' num2str(i-1)]) = resp.result{i}; + end + resp.result = result_struct; + end + end + + if length(nsaveout) + if nout == 1 + assignin('base',saveout{1},resp.result); + resp.result = ['__VAR=' saveout{1} '|' class(resp.result) '(' mat2str(size(resp.result)) ')']; + elseif nout > 1 + tmp_result = ''; + for i=1:nout + assignin('base',saveout{i},resp.result{i}); + tmp_result = ['__VAR=' saveout{i} '|' class(resp.result{i}) '(' mat2str(size(resp.result{i})) ');' tmp_result]; + end + resp.result = tmp_result; + end 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..ccd7bf4 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/pymatbridge.py b/pymatbridge/pymatbridge.py index 8e6c95c..f5b18b8 100644 --- a/pymatbridge/pymatbridge.py +++ b/pymatbridge/pymatbridge.py @@ -1,230 +1,574 @@ -""" -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 """ - -import os, time -import zmq -import subprocess +from __future__ import print_function +import collections +import functools +import json +import numpy as np +import os import platform +import subprocess import sys +import time +import types +import weakref +import zmq +import tempfile +import hashlib +import shutil +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, - platform=None, startup_options=None): + id='python-matlab-bridge', log=False, timeout=30, + platform=None, startup_options=None, + capture_stdout=True): + """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 + capture_stdout: capture (hide) matlab stdout, such as disp() + and redirect to /dev/null/ + """ - Initialize this thing. + self.MATLAB_FOLDER = os.path.join(os.path.realpath(os.path.dirname(__file__)), 'matlab') - Parameters - ---------- + # Setup internal state variables + self.started = False + self.matlab = matlab + self.socket_addr = socket_addr + self.id = id + self.timeout = timeout + self.capture_stdout = capture_stdout + self._log = log + self._path_cache = None + + # 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: + default_socket_addr = "ipc:///tmp/pymatbridge" + default_options = ['-nodesktop', '-nosplash'] - 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 + self.socket_addr = socket_addr if socket_addr else default_socket_addr + self.startup_options = startup_options if startup_options else default_options - socket_addr : str - A string that represents a valid ZMQ socket address, such as - "ipc:///tmp/pymatbridge", "tcp://127.0.0.1:55555", etc. + # initialize the ZMQ socket + self.context = zmq.Context() + self.socket = self.context.socket(zmq.REQ) - id : str - An identifier for this instance of the pymatbridge + # 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) - log : bool - Whether to save a log file in some known location. + #generate a temporary directory to run code in + self.tempdir_code = tempfile.mkdtemp(prefix='pymatlabridge',suffix='code') - maxtime : float - The maximal time to wait for a response from matlab (optional, - Default is 10 sec) + def __del__(self): + """Forcibly cleanup resources - platform : string - The OS of the machine on which this is running. Per default this - will be taken from sys.platform. + 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. """ - # Setup internal state variables - self.started = False - self.running = False - self.matlab = matlab - self.socket_addr = socket_addr + self.stop() - self.id = id - self.log = log - self.maxtime = maxtime + def _ensure_in_path(self, path): + if not os.path.isfile(path): + raise ValueError("not a valid matlab file: %s" % path) - if platform is None: - self.platform = sys.platform - else: - self.platform = platform + path, filename = os.path.split(path) + funcname, ext = os.path.splitext(filename) - 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" + if self._path_cache is None: + self._path_cache = self.path().split(os.pathsep) + if path not in self._path_cache: + self.addpath(path) + self._path_cache.append(path) - if startup_options: - self.startup_options = startup_options - elif self.platform == 'win32': - self.startup_options = ' -automation -noFigureWindows' - else: - self.startup_options = ' -nodesktop -nodisplay' + return path,funcname - self.context = None - self.socket = None + def log(self, msg): + if self._log: + print(msg) - # 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 if self.capture_stdout else None) # 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 - # Matlab should respond with "exit" if successful - if resp == "exit": - print "MATLAB closed" + """ + if not self.started: + return + 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 + shutil.rmtree(self.tempdir_code) + + 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: - sys.stdout.write('.') - 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 + + 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 - # 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) + 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) + + # receive the response resp = self.socket.recv_string() - resp = json.loads(resp, object_hook=as_complex) + resp = AttributeDict(json.loads(resp, cls=MatlabDecoder)) + if not resp.success: + raise RuntimeError(resp.result +': '+ resp.message) + + if hasattr(resp, 'result') and isinstance(resp.result, dict) and 'nout' in resp.result: + resp.result = [resp.result['a%d'%i] for i in range(resp.result['nout'])] return resp - # 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) + 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 - 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) + 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. - return resp + 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 + + """ + # 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) + + # create a new method instance + method_instance = Method(weakref.ref(self), name) + method_instance.__name__ = name + + # bind to the Matlab instance with a weakref (to avoid circular references) + setattr(self, name, types.MethodType(method_instance, Matlab)) + return getattr(self, name) + + + def run_func(self, func_path, *args, **kwargs): + path, funcname = self._ensure_in_path(func_path) + return self.bind_method(funcname)(*args, **kwargs) + + def run_script(self, script_path): + path, funcname = self._ensure_in_path(script_path) + return self.evalin('base',"run('%s')" % funcname, nout=0) + + def run_code(self, code): + #write a temporary file + fn = os.path.join(self.tempdir_code, + 'code_' + hashlib.md5(code).hexdigest() + '.m') + if not os.path.isfile(fn): + with open(fn,'w') as f: + f.write(code) + return self.run_script(fn) 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._parent = parent + self.name = name + self.doc = None + + self.parent.log("CREATED: %s" % self.name) + + def __call__(self, _, *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) + + """ + self.parent.log("CALL: %s" % self.name) + + # parse out number of output arguments + nout = kwargs.pop('nout', None) + saveout = kwargs.pop('saveout',None) + if nout is None: + saveout = [] + else: + if saveout is not None: + if len(saveout) != nout: + raise ValueError('saveout should be the same length as nout') + + # convert keyword arguments to arguments + args += tuple(item for pair in zip(kwargs.keys(), kwargs.values()) for item in pair) + + #now convert to a dict with string(num) keys because of the ambiguity + #of JSON wrt decoding [[1,2],[3,4]] (2 array args get decoded as a single + #matrix argument + nin = len(args) + args = {'a%d'%i:a for i,a in enumerate(args)} + + # build request + so = ';'.join(saveout) + ';' if saveout else '' + req = {'cmd': 'call', 'func': self.name, 'args': args, 'nin': nin, 'nout': nout, 'saveout': so} + + self.parent.log("REQ: %r:"%req) + + 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