diff --git a/pymatbridge/matlab/util/json_v0.2.2/json/json_dump.m b/pymatbridge/matlab/util/json_v0.2.2/json/json_dump.m index 8f83aac..c085226 100644 --- a/pymatbridge/matlab/util/json_v0.2.2/json/json_dump.m +++ b/pymatbridge/matlab/util/json_v0.2.2/json/json_dump.m @@ -157,7 +157,11 @@ obj = javaObject('org.json.JSONObject'); keys = fieldnames(value); for i = 1:length(keys) - obj.put(keys{i},dump_data_(value.(keys{i}), options)); + try + obj.put(keys{i},dump_data_(value.(keys{i}), options)); + catch ME + obj.put(keys{i}, dump_data_(ME.message, options)) + end end else error('json:typeError', 'Unsupported data type: %s', class(value)); diff --git a/pymatbridge/pymatbridge.py b/pymatbridge/pymatbridge.py index 7659a12..c69120e 100644 --- a/pymatbridge/pymatbridge.py +++ b/pymatbridge/pymatbridge.py @@ -29,6 +29,8 @@ import subprocess import sys import json +import types +import weakref from uuid import uuid4 from numpy import ndarray, generic, float64, frombuffer, asfortranarray @@ -263,22 +265,29 @@ def is_function_processor_working(self): def _json_response(self, **kwargs): return json.loads(self._response(**kwargs), object_hook=decode_pymat) - def run_func(self, func_path, func_args=None, nargout=1): + def run_func(self, func_path, *func_args, **kwargs): """Run a function in Matlab and return the result. Parameters ---------- func_path: str Name of function to run or a path to an m-file. - func_args: object + func_args: object, optional Function args to send to the function. - nargout: int + nargout: int, optional Desired number of return arguments. + kwargs: + 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) Returns ------- Result dictionary with keys: 'message', 'result', and 'success' """ + nargout = kwargs.pop('nargout', 1) + func_args += tuple(item for pair in zip(kwargs.keys(), kwargs.values()) + for item in pair) return self._json_response(cmd='run_function', func_path=func_path, func_args=func_args, @@ -323,6 +332,64 @@ def _set_sparse_variable(self, varname, value): self.run_code('clear {0}keys {0}values'.format(prefix)) return result + def __getattr__(self, name): + """If an attribute is not found, try to create a bound method""" + 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. + + Parameters + ---------- + name : str + The name of the Matlab function to call + e.g. 'sqrt', 'sum', 'svd', etc + unconditionally : bool, optional + Bind the method without performing + checks. Used to bootstrap methods that are required and + know to exist + + Returns + ------- + MatlabFunction + A reference to a newly bound MatlabFunction 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 + exists = self.run_func('exist', name)['result'] in [2, 3, 5] + if not unconditionally and not exists: + raise AttributeError("'Matlab' object has no attribute '%s'" % name) + + # create a new method instance + method_instance = MatlabFunction(weakref.ref(self), name) + method_instance.__name__ = name + + # bind to the Matlab instance with a weakref (to avoid circular references) + if sys.version.startswith('3'): + method = types.MethodType(method_instance, weakref.ref(self)) + else: + method = types.MethodType(method_instance, weakref.ref(self), + _Session) + setattr(self, name, method) + return getattr(self, name) + class Matlab(_Session): def __init__(self, executable='matlab', socket_addr=None, @@ -433,3 +500,59 @@ def _preamble_code(self): def _execute_flag(self): return '--eval' + + +class MatlabFunction(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. + + Parameters + ---------- + parent: Matlab instance + A reference to the parent (Matlab instance) to which the + MatlabFunction is being bound + name: str + The name of the Matlab function this represents + """ + self.name = name + self._parent = parent + self.doc = None + + def __call__(self, unused_parent_weakref, *args, **kwargs): + """Call a function with the supplied arguments in the Matlab subprocess + + Passes parameters to `run_func`. + + """ + return self.parent.run_func(self.name, *args, **kwargs) + + @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)['result'] + return self.doc diff --git a/pymatbridge/tests/test_functions.py b/pymatbridge/tests/test_functions.py new file mode 100644 index 0000000..4fed51c --- /dev/null +++ b/pymatbridge/tests/test_functions.py @@ -0,0 +1,57 @@ +import numpy as np +import numpy.testing as npt +import test_utils as tu + + +class TestFunctions(object): + + # Start a Matlab session before running any tests + @classmethod + def setup_class(cls): + cls.mlab = tu.connect_to_matlab() + + # Tear down the Matlab session after running all the tests + @classmethod + def teardown_class(cls): + tu.stop_matlab(cls.mlab) + + def test_nargout(self): + res = self.mlab.run_func('svd', np.array([[1,2],[1,3]]), nargout=3) + U, S, V = res['result'] + npt.assert_almost_equal(U, np.array([[-0.57604844, -0.81741556], + [-0.81741556, 0.57604844]])) + + npt.assert_almost_equal(S, np.array([[ 3.86432845, 0.], + [ 0., 0.25877718]])) + + npt.assert_almost_equal(V, np.array([[-0.36059668, -0.93272184], + [-0.93272184, 0.36059668]])) + + res = self.mlab.run_func('svd', np.array([[1,2],[1,3]]), nargout=1) + s = res['result'] + npt.assert_almost_equal(s, [[ 3.86432845], [ 0.25877718]]) + + res = self.mlab.run_func('close', 'all', nargout=0) + assert res['result'] == [] + + def test_tuple_args(self): + res = self.mlab.run_func('ones', (1, 2)) + npt.assert_almost_equal(res['result'], [[1, 1]]) + + res = self.mlab.run_func('chol', + np.array([[2, 2], [1, 1]]), 'lower') + npt.assert_almost_equal(res['result'], + [[1.41421356, 0.], + [0.70710678, 0.70710678]]) + + def test_create_func(self): + test = self.mlab.ones(3) + npt.assert_array_equal(test['result'], np.ones((3, 3))) + doc = self.mlab.zeros.__doc__ + assert 'zeros' in doc + + def test_pass_kwargs(self): + resp = self.mlab.run_func('plot', [1, 2, 3], Linewidth=3) + assert resp['success'] == 'true' + resp = self.mlab.plot([1, 2, 3], Linewidth=3) + assert resp['result'] is not None diff --git a/pymatbridge/tests/test_run_code.py b/pymatbridge/tests/test_run_code.py index 2b7714f..abd903e 100644 --- a/pymatbridge/tests/test_run_code.py +++ b/pymatbridge/tests/test_run_code.py @@ -64,33 +64,3 @@ def test_undefined_code(self): npt.assert_equal(message, "'this_is_nonsense' undefined near line 1 column 1") else: npt.assert_equal(message, "Undefined function or variable 'this_is_nonsense'.") - - - def test_nargout(self): - res = self.mlab.run_func('svd', np.array([[1,2],[1,3]]), nargout=3) - U, S, V = res['result'] - npt.assert_almost_equal(U, np.array([[-0.57604844, -0.81741556], - [-0.81741556, 0.57604844]])) - - npt.assert_almost_equal(S, np.array([[ 3.86432845, 0.], - [ 0., 0.25877718]])) - - npt.assert_almost_equal(V, np.array([[-0.36059668, -0.93272184], - [-0.93272184, 0.36059668]])) - - res = self.mlab.run_func('svd', np.array([[1,2],[1,3]]), nargout=1) - s = res['result'] - npt.assert_almost_equal(s, [[ 3.86432845], [ 0.25877718]]) - - res = self.mlab.run_func('close', 'all', nargout=0) - assert res['result'] == [] - - def test_tuple_args(self): - res = self.mlab.run_func('ones', (1, 2)) - npt.assert_almost_equal(res['result'], [[1, 1]]) - - res = self.mlab.run_func('chol', - (np.array([[2, 2], [1, 1]]), 'lower')) - npt.assert_almost_equal(res['result'], - [[1.41421356, 0.], - [0.70710678, 0.70710678]])