From 12e0411ed0d0300ed359d98ce1fa60552f2503d9 Mon Sep 17 00:00:00 2001 From: Hilton Bristow Date: Mon, 7 Apr 2014 19:12:30 +1000 Subject: [PATCH 1/7] Initial pythonic API teaser --- pymatbridge/matlab/matlabserver.m | 72 +++--- pymatbridge/matlab/util/pymat_call.m | 56 +++++ pymatbridge/matlab/util/pymat_eval.m | 18 +- pymatbridge/matlab/util/pymat_feval.m | 16 +- pymatbridge/matlab/util/pymat_get_variable.m | 29 +-- pymatbridge/pymatbridge.py | 218 ++++++++++++------- 6 files changed, 265 insertions(+), 144 deletions(-) create mode 100644 pymatbridge/matlab/util/pymat_call.m diff --git a/pymatbridge/matlab/matlabserver.m b/pymatbridge/matlab/matlabserver.m index 55b223b..2f5d298 100644 --- a/pymatbridge/matlab/matlabserver.m +++ b/pymatbridge/matlab/matlabserver.m @@ -6,36 +6,46 @@ function matlabserver(socket_address) json.startup messenger('init', socket_address); -while(1) - msg_in = messenger('listen'); - req = json.load(msg_in); - - switch(req.cmd) - case {'connect'} - messenger('respond', 'connected'); - - case {'exit'} - messenger('exit'); - clear mex; - break; - - case {'run_function'} - fhandle = str2func('pymat_feval'); - resp = feval(fhandle, req); - messenger('respond', resp); - - case {'run_code'} - fhandle = str2func('pymat_eval'); - resp = feval(fhandle, req); - messenger('respond', resp); - - case {'get_var'} - fhandle = str2func('pymat_get_variable'); - resp = feval(fhandle, req); - messenger('respond', resp); - - otherwise - messenger('respond', 'i dont know what you want'); +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'); + + case {'exit'} + messenger('exit'); + clear mex; + break; + + case {'call'} + fhandle = str2func('pymat_call') + resp = feval(fhandle, req) + messenger('respond', resp) + + case {'eval'} + fhandle = str2func('pymat_eval'); + resp = feval(fhandle, req); + messenger('respond', resp); + + case {'get_var'} + fhandle = str2func('pymat_get_variable'); + resp = feval(fhandle, req); + messenger('respond', resp); + + otherwise + messenger('respond', 'unrecognized command'); + end + + catch exception + response.success = false; + response.result = exception.identifier; + response.message = exception.message; + + json_response = json.dump(response); + messenger('respond', 'json_response'); end - end diff --git a/pymatbridge/matlab/util/pymat_call.m b/pymatbridge/matlab/util/pymat_call.m new file mode 100644 index 0000000..96f17bc --- /dev/null +++ b/pymatbridge/matlab/util/pymat_call.m @@ -0,0 +1,56 @@ +function json_response = pymat_call(req) + + if ~isfield(req, 'func') + response.message = 'No function given as func POST parameter'; + json_response = json.dump(response); + return + end + + if ~isfield(req, 'args') + req.args = {} + end + + func = str2func(req.func); + if isfield(req, 'nout') + nout = req.nout + else + try + nout = min(abs(nargout(func)), 1); + catch + nout = 0; + end + end + + try + switch nout + case 0 + feval(func, req.args{:}); + response.result = true; + case 1 + a = feval(func, req.args{:}); + response.result = a; + case 2 + [a, b] = feval(func, req.args{:}); + response.result = {a, b}; + case 3 + [a, b, c] = feval(func, req.args{:}); + response.result = {a, b, c}; + case 4 + [a, b, c, d] = feval(func, req.args{:}); + response.result = {a, b, c, d}; + default + % varargout or throw exception + response.result = feval(func, req.args{:}); + end + response.success = true; + response.message = 'Successfully completed request'; + catch exception + response.success = false; + response.result = exception.identifier; + response.message = exception.message; + end + + json_response = json.dump(response); + return + +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/pymatbridge.py b/pymatbridge/pymatbridge.py index 9e9a347..8c69a35 100644 --- a/pymatbridge/pymatbridge.py +++ b/pymatbridge/pymatbridge.py @@ -8,16 +8,40 @@ This is a modified version using ZMQ, Haoxing Zhang Jan.2014 """ - -import numpy as np -import os, time -import zmq -import subprocess +from __future__ import print_function +import collections +import functools +import json +import os import platform +import subprocess import sys +import time +import types +import weakref +import zmq +try: + basestring + DEVNULL = open(os.devnull, 'w') +except: + basestring = str + DEVNULL = subprocess.DEVNULL + +# ---------------------------------------------------------------------------- +# HELPERS +# ---------------------------------------------------------------------------- +def chain(*iterables): + for iterable in iterables: + if not isinstance(iterable, collections.Iterable) or isinstance(iterable, basestring): + yield iterable + else: + for item in iterable: + yield item -import json +# ---------------------------------------------------------------------------- +# JSON EXTENSION +# ---------------------------------------------------------------------------- # JSON encoder extension to handle complex numbers class ComplexEncoder(json.JSONEncoder): def default(self, obj): @@ -33,32 +57,18 @@ def as_complex(dct): return dct +# ---------------------------------------------------------------------------- +# MATLAB +# ---------------------------------------------------------------------------- MATLAB_FOLDER = '%s/matlab' % os.path.realpath(os.path.dirname(__file__)) -# 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) - - subprocess.Popen(command, shell = True, stdin=subprocess.PIPE) - - return True - - class Matlab(object): """ A class for communicating with a matlab session """ def __init__(self, matlab='matlab', socket_addr=None, - id='python-matlab-bridge', log=False, maxtime=60, + id='python-matlab-bridge', log=False, maxtime=30, platform=None, startup_options=None): """ Initialize this thing. @@ -94,6 +104,7 @@ def __init__(self, matlab='matlab', socket_addr=None, self.running = False self.matlab = matlab self.socket_addr = socket_addr + self.blacklist = set() self.id = id self.log = log @@ -114,44 +125,63 @@ def __init__(self, matlab='matlab', socket_addr=None, else: self.startup_options = ' -nodesktop -nodisplay' - self.context = None - self.socket = None + self.context = zmq.Context() + self.socket = self.context.socket(zmq.REQ) + # generate some useful matlab builtins + getattr(self, 'addpath') + getattr(self, 'eval') + getattr(self, 'help') + getattr(self, 'license') + getattr(self, 'run') + getattr(self, 'run') + getattr(self, 'version') + + def __del__(self): + self.socket.close() + try: + self.matlab_process.terminate() + except: + pass + + # ------------------------------------------------------------------------ + # START/STOP SERVER + # ------------------------------------------------------------------------ # 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) + command = chain( + self.matlab, + self.startup_options, + '-r', + "\"addpath(genpath('%s')),matlabserver('%s'),exit\"" % (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('ready') else: - print "MATLAB failed to start" - return False + raise RuntimeError('Matlab failed to start') # Stop the Matlab server def stop(self): - req = json.dumps(dict(cmd="exit"), cls=ComplexEncoder) + req = json.dumps({'cmd': 'exit'}, cls=ComplexEncoder) self.socket.send(req) resp = self.socket.recv_string() # Matlab should respond with "exit" if successful if resp == "exit": - print "MATLAB closed" - + print("Matlab subprocess stopped") self.started = False - return True # To test if the client can talk to the server def is_connected(self): @@ -171,10 +201,11 @@ def is_connected(self): else: return False except zmq.ZMQError: - np.disp(".", linefeed=False) + print('.', end='') + sys.stdout.flush() time.sleep(1) if (time.time() - start_time > self.maxtime) : - print "Matlab session timed out after %d seconds" % (self.maxtime) + print('failed to connect to Matlab after %d seconds' % self.maxtime) return False @@ -185,47 +216,74 @@ def is_function_processor_working(self): else: return False - - # 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) - - req = dict(cmd="run_function") - req['func_path'] = func_path - req['func_args'] = func_args - + def _execute(self, req): 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 - - # 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) - - 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) - - return resp - - def get_variable(self, varname, maxtime=None): - if self.running: - time.sleep(0.05) - - 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'] - + + + # ------------------------------------------------------------------------ + # PYTHONIC API + # ------------------------------------------------------------------------ + def __getattr__(self, name): + if name in self.blacklist: + raise AttributeError(attribute_msg.format(name)) + method_instance = Method(self, name) + method_instance.__name__ = name + ' (unverified)' + setattr(self, name, types.MethodType(method_instance, weakref.ref(self), Matlab)) + return getattr(self, name) + + def get_variable(self, varname, timeout=None): + req = { + 'cmd': 'get_var', + 'varname': varname, + } + resp = self._execute(req) + if not resp['success']: + raise RuntimeError(resp['result'] +': '+ resp['message']) + return resp['result'] + + def clear_blacklist(self): + self.blacklist = set() + + +# ---------------------------------------------------------------------------- +# MATLAB METHOD +# ---------------------------------------------------------------------------- +attribute_msg = "attribute '{0}' does not correspond to a Matlab function and was blacklisted" + +class Method(object): + + def __init__(self, parent, name): + self.name = name + self.doc = None + + def __call__(self, parent, *args, **kwargs): + nout = kwargs.pop('nout', None) + args += tuple(item for pair in zip(kwargs.keys(), kwargs.values()) for item in pair) + req = { + 'cmd': 'call', + 'func': self.name, + 'args': args + } + if nout: + req['nout'] = nout + resp = parent()._execute(req) + if not resp['success']: + if resp['result'] == 'MATLAB:UndefinedFunction': + parent().blacklist.add(self.name) + delattr(parent(), self.name) + raise AttributeError(attribute_msg.format(self.name)) + raise RuntimeError(resp['result'] +': '+ resp['message']) + else: + self.__name__ = self.name + ' (verified)' + return resp['result'] + + @property + def __doc__(self, parent): + if not self.doc: + self.doc = parent().help(self.name) + return self.doc From a8cd5cc7ff417fb018e6635fecf0ed15fcf56e48 Mon Sep 17 00:00:00 2001 From: Hilton Bristow Date: Tue, 8 Apr 2014 22:49:13 +1000 Subject: [PATCH 2/7] streamlined API --- pymatbridge/matlab/matlabserver.m | 134 +++++--- pymatbridge/matlab/util/pymat_call.m | 56 --- pymatbridge/pymatbridge.py | 487 ++++++++++++++++++--------- 3 files changed, 420 insertions(+), 257 deletions(-) delete mode 100644 pymatbridge/matlab/util/pymat_call.m diff --git a/pymatbridge/matlab/matlabserver.m b/pymatbridge/matlab/matlabserver.m index 2f5d298..91df534 100644 --- a/pymatbridge/matlab/matlabserver.m +++ b/pymatbridge/matlab/matlabserver.m @@ -1,51 +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 - -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); - - switch(req.cmd) - case {'connect'} - messenger('respond', 'connected'); - - case {'exit'} - messenger('exit'); - clear mex; - break; - - case {'call'} - fhandle = str2func('pymat_call') - resp = feval(fhandle, req) - messenger('respond', resp) - - case {'eval'} - fhandle = str2func('pymat_eval'); - resp = feval(fhandle, req); - messenger('respond', resp); - - case {'get_var'} - fhandle = str2func('pymat_get_variable'); - resp = feval(fhandle, req); - messenger('respond', resp); - - otherwise - messenger('respond', 'unrecognized 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); + + 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 {'ping'} + messenger('respond', 'pong'); + + case {'exit'} + messenger('exit'); + clear mex; + break; + + case {'call'} + resp = call(req); + json_resp = json.dump(resp); + messenger('respond', json_resp); + + otherwise + throw(MException('MATLAB:matlabserver', ['Unrecognized command ' req.cmd])) + end + + catch exception + % format the exception and pass it back to the client + resp.success = false; + resp.result = exception.identifier; + resp.message = exception.message; + + 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 - catch exception - response.success = false; - response.result = exception.identifier; - response.message = exception.message; + % 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 - json_response = json.dump(response); - messenger('respond', 'json_response'); + % call the function, taking care of broadcasting outputs + switch nout + case 0 + func(req.args{:}); + case 1 + resp.result = func(req.args{:}); + otherwise + [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_call.m b/pymatbridge/matlab/util/pymat_call.m deleted file mode 100644 index 96f17bc..0000000 --- a/pymatbridge/matlab/util/pymat_call.m +++ /dev/null @@ -1,56 +0,0 @@ -function json_response = pymat_call(req) - - if ~isfield(req, 'func') - response.message = 'No function given as func POST parameter'; - json_response = json.dump(response); - return - end - - if ~isfield(req, 'args') - req.args = {} - end - - func = str2func(req.func); - if isfield(req, 'nout') - nout = req.nout - else - try - nout = min(abs(nargout(func)), 1); - catch - nout = 0; - end - end - - try - switch nout - case 0 - feval(func, req.args{:}); - response.result = true; - case 1 - a = feval(func, req.args{:}); - response.result = a; - case 2 - [a, b] = feval(func, req.args{:}); - response.result = {a, b}; - case 3 - [a, b, c] = feval(func, req.args{:}); - response.result = {a, b, c}; - case 4 - [a, b, c, d] = feval(func, req.args{:}); - response.result = {a, b, c, d}; - default - % varargout or throw exception - response.result = feval(func, req.args{:}); - end - response.success = true; - response.message = 'Successfully completed request'; - catch exception - response.success = false; - response.result = exception.identifier; - response.message = exception.message; - end - - json_response = json.dump(response); - return - -end diff --git a/pymatbridge/pymatbridge.py b/pymatbridge/pymatbridge.py index 8c69a35..6d2bac9 100644 --- a/pymatbridge/pymatbridge.py +++ b/pymatbridge/pymatbridge.py @@ -1,12 +1,9 @@ -""" -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 @@ -21,16 +18,31 @@ import weakref import zmq try: - basestring - DEVNULL = open(os.devnull, 'w') + # Python 2 + basestring + DEVNULL = open(os.devnull, 'w') except: - basestring = str - DEVNULL = subprocess.DEVNULL + # 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 + + """ for iterable in iterables: if not isinstance(iterable, collections.Iterable) or isinstance(iterable, basestring): yield iterable @@ -39,19 +51,49 @@ def chain(*iterables): yield item +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 + + """ + def __init__(self, *args, **kwargs): + super(AttributeDict, self).__init__(*args, **kwargs) + self.__dict__ = self + + # ---------------------------------------------------------------------------- # JSON EXTENSION # ---------------------------------------------------------------------------- -# JSON encoder extension to handle complex numbers class ComplexEncoder(json.JSONEncoder): + """A JSON extension for encoding complex numbers + + ComplexEncoder encodes complex numbers as a mapping, + complex --> {'real': complex.real, 'imag': complex.imag} + + """ 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): + """A JSON extension for decoding complex numbers + + as_complex decodes mappings of the form {'real': real_val, 'imag': imag_val} + into a single value of type complex + + Args: + dct: A dictionary + + Returns: + complex: A complex number if the dictionary represents an encoding + of a complex number, else the dictionary + + """ if 'real' in dct and 'imag' in dct: return complex(dct['real'], dct['imag']) return dct @@ -60,101 +102,96 @@ def as_complex(dct): # ---------------------------------------------------------------------------- # MATLAB # ---------------------------------------------------------------------------- -MATLAB_FOLDER = '%s/matlab' % os.path.realpath(os.path.dirname(__file__)) - class Matlab(object): - """ - A class for communicating with a matlab session - """ - def __init__(self, matlab='matlab', socket_addr=None, - id='python-matlab-bridge', log=False, maxtime=30, + 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.blacklist = set() - 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', '-nofigureWindows'] else: - self.platform = platform + default_socket_addr = "ipc:///tmp/pymatbridge" + default_options = ['-nodesktop', '-nodisplay'] - 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 startup_options: - self.startup_options = startup_options - elif self.platform == 'win32': - self.startup_options = ' -automation -noFigureWindows' - else: - self.startup_options = ' -nodesktop -nodisplay' + self.socket_addr = socket_addr if socket_addr else default_socket_addr + self.startup_options = startup_options if startup_options else default_options + # initialize the ZMQ socket self.context = zmq.Context() self.socket = self.context.socket(zmq.REQ) - # generate some useful matlab builtins - getattr(self, 'addpath') - getattr(self, 'eval') - getattr(self, 'help') - getattr(self, 'license') - getattr(self, 'run') - getattr(self, 'run') - getattr(self, 'version') + # 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): - self.socket.close() - try: - self.matlab_process.terminate() - except: - pass + """Forcibly cleanup resources + + 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/STOP SERVER - # ------------------------------------------------------------------------ - # Start server/client session and make the connection def start(self): - # Start the MATLAB server in a new process + """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\"" % (MATLAB_FOLDER, self.socket_addr) + "\"addpath(genpath('%s')),matlabserver('%s'),exit\"" % (self.MATLAB_FOLDER, self.socket_addr) ) command = ' '.join(command) @@ -167,123 +204,261 @@ def start(self): # Test if connection is established if (self.is_connected()): - print('ready') + print('started') else: + self.started = False raise RuntimeError('Matlab failed to start') + def stop(self, timeout=1): + """Stop the Matlab subprocess + + 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 - # Stop the Matlab server - def stop(self): req = json.dumps({'cmd': 'exit'}, cls=ComplexEncoder) - self.socket.send(req) - resp = self.socket.recv_string() + try: + # the user might be stopping Matlab because the socket is in a bad state + self.socket.send(req) + except: + pass - # Matlab should respond with "exit" if successful - if resp == "exit": - print("Matlab subprocess stopped") + 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 - # 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'}, cls=ComplexEncoder) 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: print('.', end='') sys.stdout.flush() - time.sleep(1) - if (time.time() - start_time > self.maxtime) : - print('failed to connect to Matlab after %d seconds' % self.maxtime) - return False + 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(self, req): + 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 + + Returns: + resp (dict): A dictionary containing the response from the Matlab + server containing the keys 'success', 'result', and 'message' + + 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 + + """ + # send the request req = json.dumps(req, cls=ComplexEncoder) 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, object_hook=as_complex)) + if not resp.success: + raise RuntimeError(resp.result +': '+ resp.message) return resp - - - # ------------------------------------------------------------------------ - # PYTHONIC API - # ------------------------------------------------------------------------ + def __getattr__(self, name): - if name in self.blacklist: - raise AttributeError(attribute_msg.format(name)) - method_instance = Method(self, name) - method_instance.__name__ = name + ' (unverified)' - setattr(self, name, types.MethodType(method_instance, weakref.ref(self), Matlab)) - return 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. - def get_variable(self, varname, timeout=None): - req = { - 'cmd': 'get_var', - 'varname': varname, - } - resp = self._execute(req) - if not resp['success']: - raise RuntimeError(resp['result'] +': '+ resp['message']) - return resp['result'] + 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 - def clear_blacklist(self): - self.blacklist = set() + 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, weakref.ref(self), Matlab)) + return getattr(self, name) # ---------------------------------------------------------------------------- # MATLAB METHOD # ---------------------------------------------------------------------------- -attribute_msg = "attribute '{0}' does not correspond to a Matlab function and was blacklisted" - 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, parent, *args, **kwargs): - nout = kwargs.pop('nout', 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) - req = { - 'cmd': 'call', - 'func': self.name, - 'args': args - } - if nout: - req['nout'] = nout - resp = parent()._execute(req) - if not resp['success']: - if resp['result'] == 'MATLAB:UndefinedFunction': - parent().blacklist.add(self.name) - delattr(parent(), self.name) - raise AttributeError(attribute_msg.format(self.name)) - raise RuntimeError(resp['result'] +': '+ resp['message']) - else: - self.__name__ = self.name + ' (verified)' - return resp['result'] + + # 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, parent): - if not self.doc: - self.doc = parent().help(self.name) + 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 From 95e8abba73f42f583b813399260e01c22614f26b Mon Sep 17 00:00:00 2001 From: Hilton Bristow Date: Wed, 9 Apr 2014 16:08:28 +1000 Subject: [PATCH 3/7] Added 2D numpy (de-)serialization support. 3D+ currently being mangled by Matlab --- pymatbridge/pymatbridge.py | 86 ++++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/pymatbridge/pymatbridge.py b/pymatbridge/pymatbridge.py index 6d2bac9..8f5786b 100644 --- a/pymatbridge/pymatbridge.py +++ b/pymatbridge/pymatbridge.py @@ -9,6 +9,7 @@ import collections import functools import json +import numpy as np import os import platform import subprocess @@ -67,36 +68,59 @@ def __init__(self, *args, **kwargs): # ---------------------------------------------------------------------------- # JSON EXTENSION # ---------------------------------------------------------------------------- -class ComplexEncoder(json.JSONEncoder): - """A JSON extension for encoding complex numbers +class MatlabEncoder(json.JSONEncoder): + """A JSON extension for encoding numpy arrays to Matlab format - ComplexEncoder encodes complex numbers as a mapping, + 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): + return obj.tolist() if isinstance(obj, complex): return {'real':obj.real, 'imag':obj.imag} # Handle the default case - return json.JSONEncoder.default(self, obj) - -def as_complex(dct): - """A JSON extension for decoding complex numbers + return super(MatlabEncoder, self).default(obj) - as_complex decodes mappings of the form {'real': real_val, 'imag': imag_val} - into a single value of type complex - Args: - dct: A dictionary - - Returns: - complex: A complex number if the dictionary represents an encoding - of a complex number, else the dictionary +class MatlabDecoder(json.JSONDecoder): + """A JSON extension for decoding Matlab arrays into numpy arrays + 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 """ - if 'real' in dct and 'imag' in dct: - return complex(dct['real'], dct['imag']) - return dct + 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)): + return array + else: + return [self.coerce_to_numpy(item) for item in tree] + return tree # ---------------------------------------------------------------------------- @@ -222,7 +246,7 @@ def stop(self, timeout=1): if not self.started: return - req = json.dumps({'cmd': 'exit'}, cls=ComplexEncoder) + req = json.dumps({'cmd': 'exit'}) try: # the user might be stopping Matlab because the socket is in a bad state self.socket.send(req) @@ -266,7 +290,7 @@ def is_connected(self): if not self.started: raise RuntimeError('No running Matlab subprocess to connect to') - req = json.dumps({'cmd': 'ping'}, cls=ComplexEncoder) + req = json.dumps({'cmd': 'ping'}) self.socket.send(req) start_time = time.time() @@ -327,12 +351,12 @@ def execute_in_matlab(self, req): """ # send the request - req = json.dumps(req, cls=ComplexEncoder) + req = json.dumps(req, cls=MatlabEncoder) self.socket.send(req) # receive the response resp = self.socket.recv_string() - resp = AttributeDict(json.loads(resp, object_hook=as_complex)) + resp = AttributeDict(json.loads(resp, cls=MatlabDecoder)) if not resp.success: raise RuntimeError(resp.result +': '+ resp.message) return resp @@ -382,6 +406,22 @@ def bind_method(self, name, unconditionally=False): 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): + return self.evalin('base',varname) + + # ---------------------------------------------------------------------------- # MATLAB METHOD # ---------------------------------------------------------------------------- From 4e804387af43e7f27b5ae3a1bce12ab241ba4c5f Mon Sep 17 00:00:00 2001 From: Hilton Bristow Date: Wed, 9 Apr 2014 16:55:45 +1000 Subject: [PATCH 4/7] Improve socket reboot --- pymatbridge/pymatbridge.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pymatbridge/pymatbridge.py b/pymatbridge/pymatbridge.py index 8f5786b..7e756b9 100644 --- a/pymatbridge/pymatbridge.py +++ b/pymatbridge/pymatbridge.py @@ -223,6 +223,7 @@ def start(self): self.matlab_process = subprocess.Popen(command, shell=True, stdin=subprocess.PIPE, stdout=DEVNULL) # Start the client + self.socket = self.context.socket(zmq.REQ) self.socket.connect(self.socket_addr) self.started = True From 1ba4829a1943af99e6fd8aaf0b47e6716688f658 Mon Sep 17 00:00:00 2001 From: Hilton Bristow Date: Wed, 9 Apr 2014 23:37:59 +1000 Subject: [PATCH 5/7] Multi-dimensional numpy array support --- pymatbridge/pymatbridge.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pymatbridge/pymatbridge.py b/pymatbridge/pymatbridge.py index 7e756b9..8556905 100644 --- a/pymatbridge/pymatbridge.py +++ b/pymatbridge/pymatbridge.py @@ -78,7 +78,9 @@ class MatlabEncoder(json.JSONEncoder): """ def default(self, obj): if isinstance(obj, np.ndarray): - return obj.tolist() + 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 @@ -117,7 +119,9 @@ def coerce_to_numpy(self, tree): if isinstance(tree, list): array = np.array(tree) if isinstance(array.dtype.type(), (bool, int, float, complex)): - return array + 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 From de4d9b9aee33a231401cd53234f6c77947f05728 Mon Sep 17 00:00:00 2001 From: Hilton Bristow Date: Thu, 10 Apr 2014 11:48:13 +1000 Subject: [PATCH 6/7] Allow graphical plots by default. Draw the plots with a call to matlab.drawnow(). TODO: Allow the EDT to run while zmq is blocking for a request --- pymatbridge/pymatbridge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pymatbridge/pymatbridge.py b/pymatbridge/pymatbridge.py index 8556905..be8d9e0 100644 --- a/pymatbridge/pymatbridge.py +++ b/pymatbridge/pymatbridge.py @@ -173,10 +173,10 @@ def __init__(self, matlab='matlab', socket_addr=None, self.platform = platform if platform else sys.platform if self.platform == 'win32': default_socket_addr = "tcp://127.0.0.1:55555" - default_options = ['-automation', '-nofigureWindows'] + default_options = ['-automation'] else: default_socket_addr = "ipc:///tmp/pymatbridge" - default_options = ['-nodesktop', '-nodisplay'] + default_options = ['-nodesktop', '-nosplash'] self.socket_addr = socket_addr if socket_addr else default_socket_addr self.startup_options = startup_options if startup_options else default_options From 07acc4eea413c9e11acbcc79a4e5a6479d45941d Mon Sep 17 00:00:00 2001 From: Hilton Bristow Date: Thu, 10 Apr 2014 16:21:43 +1000 Subject: [PATCH 7/7] Migrated setup.py to setuptools. Merged version.py into setup.py. Bumped minor version number to signify new API --- THANKS => CONTRIBUTORS.md | 0 LICENSE | 24 +++++-- pymatbridge/publish.py | 17 +++++ pymatbridge/version.py | 99 ---------------------------- scripts/publish-notebook | 17 ----- setup.py | 132 ++++++++++++++++++++++---------------- 6 files changed, 114 insertions(+), 175 deletions(-) rename THANKS => CONTRIBUTORS.md (100%) delete mode 100644 pymatbridge/version.py delete mode 100755 scripts/publish-notebook 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/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/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' + ] +)