From f59d49827757b60633e5fa6dac601c745f64ce02 Mon Sep 17 00:00:00 2001 From: Duc Le Date: Mon, 27 May 2024 11:46:22 +0100 Subject: [PATCH 1/8] Add indexing ops to DictPropertyWrapper --- libpymcr/MatlabProxyObject.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libpymcr/MatlabProxyObject.py b/libpymcr/MatlabProxyObject.py index a4c3e43..a951fb5 100644 --- a/libpymcr/MatlabProxyObject.py +++ b/libpymcr/MatlabProxyObject.py @@ -51,6 +51,16 @@ def __setattr__(self, name, value): self.val[name] = value setattr(self.parent, self.name, self.val) + def __getitem__(self, name): + rv = self.val[name] + if isinstance(rv, dict): + rv = DictPropertyWrapper(rv, name, self) + return rv + + def __setitem__(self, name, value): + self.val[name] = value + setattr(self.parent, self.name, self.val) + def __repr__(self): rv = "Matlab struct with fields:\n" for k, v in self.val.items(): From be7779af0c7bd6efd77930287b21acc85c98e815 Mon Sep 17 00:00:00 2001 From: Duc Le Date: Mon, 27 May 2024 11:50:32 +0100 Subject: [PATCH 2/8] Update matlab checker to be more robust --- libpymcr/utils.py | 59 +++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/libpymcr/utils.py b/libpymcr/utils.py index 2b1c420..58d4b3e 100644 --- a/libpymcr/utils.py +++ b/libpymcr/utils.py @@ -117,11 +117,13 @@ def get_version_from_ctf(ctffile): def get_matlab_from_registry(version=None): # Searches for the Mathworks registry key and finds the Matlab path from that + if version is not None and version.startswith('R') and version in MLVERDIC.keys(): + version = MLVERDIC[version] retval = [] try: import winreg except ImportError: - return retval + return None for installation in ['MATLAB', 'MATLAB Runtime', 'MATLAB Compiler Runtime']: try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f'SOFTWARE\\MathWorks\\{installation}') as key: @@ -134,14 +136,14 @@ def get_matlab_from_registry(version=None): for v in versions: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f'SOFTWARE\\MathWorks\\{installation}\\{v}') as key: retval.append(winreg.QueryValueEx(key, 'MATLABROOT')[0]) - return retval + return retval[0] class DetectMatlab(object): - def __init__(self, version): + def __init__(self, version=None): self.ver = version - self.PLATFORM_DICT = {'Windows': ['PATH', 'dll', ''], 'Linux': ['LD_LIBRARY_PATH', 'so', 'libmw'], - 'Darwin': ['DYLD_LIBRARY_PATH', 'dylib', 'libmw']} + self.PLATFORM_DICT = {'Windows': ['PATH', 'dll', '', 'exe', ';'], 'Linux': ['LD_LIBRARY_PATH', 'so', 'libmw', '', ':'], + 'Darwin': ['DYLD_LIBRARY_PATH', 'dylib', 'libmw', '', ':']} # Note that newer Matlabs are 64-bit only self.ARCH_DICT = {'Windows': {'64bit': 'win64', '32bit': 'pcwin32'}, 'Linux': {'64bit': 'glnxa64', '32bit': 'glnx86'}, @@ -151,21 +153,20 @@ def __init__(self, version): self.REQ_DIRS = {'Windows':[DIRS[0]], 'Darwin':DIRS[:3], 'Linux':DIRS} self.system = platform.system() if self.system not in self.PLATFORM_DICT: - raise RuntimeError('{0} is not a supported platform.'.format(self.system)) - (self.path_var, self.ext, self.lib_prefix) = self.PLATFORM_DICT[self.system] + raise RuntimeError(f'Operating system {self.system} is not supported.') + (self.path_var, self.ext, self.lib_prefix, self.exe_ext, self.sep) = self.PLATFORM_DICT[self.system] self.arch = self.ARCH_DICT[self.system][platform.architecture()[0]] self.required_dirs = self.REQ_DIRS[self.system] - if self.system == 'Windows': + self.dirlevel = ('..', '..') + if self.ver is None: + self.file_to_find = ''.join(('matlab', self.exe_ext)) + self.dirlevel = ('..',) + elif self.system == 'Windows': self.file_to_find = ''.join((self.lib_prefix, 'mclmcrrt', self.ver.replace('.','_'), '.', self.ext)) - self.sep = ';' elif self.system == 'Linux': self.file_to_find = ''.join((self.lib_prefix, 'mclmcrrt', '.', self.ext, '.', self.ver)) - self.sep = ':' elif self.system == 'Darwin': self.file_to_find = ''.join((self.lib_prefix, 'mclmcrrt', '.', self.ver, '.', self.ext)) - self.sep = ':' - else: - raise RuntimeError(f'Operating system {self.system} is not supported.') @property def ver(self): @@ -177,8 +178,9 @@ def ver(self, val): if self._ver.startswith('R') and self._ver in MLVERDIC.keys(): self._ver = MLVERDIC[self._ver] - def find_version(self, root_dir): - print(f'Searching for Matlab {self.ver} in {root_dir}') + def find_version(self, root_dir, suppress_output=False): + if not suppress_output: + print(f'Searching for Matlab {self.ver} in {root_dir}') def find_file(path, filename, max_depth=3): """ Finds a file, will return first match""" for depth in range(max_depth + 1): @@ -197,13 +199,14 @@ def find_file(path, filename, max_depth=3): if ml_subdir != 'runtime': self.ver = ml_subdir ml_path = os.path.abspath(lib_path.parents[2]) - print(f'Found Matlab {self.ver} {self.arch} at {ml_path}') + if not suppress_output: + print(f'Found Matlab {self.ver} {self.arch} at {ml_path}') return ml_path else: return None - def guess_path(self, mlPath=[]): - GUESSES = {'Windows': [r'C:\Program Files\MATLAB', r'C:\Program Files (x86)\MATLAB', + def guess_path(self, mlPath=[], suppress_output=False): + GUESSES = {'Windows': [r'C:\Program Files\MATLAB', r'C:\Program Files (x86)\MATLAB', r'C:\Program Files\MATLAB\MATLAB Runtime', r'C:\Program Files (x86)\MATLAB\MATLAB Runtime'], 'Linux': ['/usr/local/MATLAB', '/opt/MATLAB', '/opt', '/usr/local/MATLAB/MATLAB_Runtime'], 'Darwin': ['/Applications/MATLAB', '/Applications/']} @@ -214,10 +217,10 @@ def guess_path(self, mlPath=[]): if self.system == 'Windows' and ':' not in ml_env: pp = ml_env.split('/')[1:] ml_env = pp[0] + ':\\' + '\\'.join(pp[1:]) - mlPath += [os.path.abspath(os.path.join(ml_env, '..', '..'))] + mlPath += [os.path.abspath(os.path.join((ml_env,) + self.dirlevel))] for possible_dir in mlPath + GUESSES[self.system]: if os.path.isdir(possible_dir): - rv = self.find_version(possible_dir) + rv = self.find_version(possible_dir, suppress_output) if rv is not None: return rv return None @@ -228,24 +231,24 @@ def guess_from_env(self, ld_path=None): if ld_path is None: return None for possible_dir in ld_path.split(self.sep): if os.path.exists(os.path.join(possible_dir, self.file_to_find)): - return os.path.abspath(os.path.join(possible_dir, '..', '..')) + return os.path.abspath(os.path.join((possible_dir,) + self.dirlevel)) return None - def guess_from_syspath(self): + def guess_from_syspath(self, suppress_output=False): matlab_exe = shutil.which('matlab') if matlab_exe is None: return None if self.system == 'Windows' else self.guess_from_env('PATH') mlbinpath = os.path.dirname(os.path.realpath(matlab_exe)) - return self.find_version(os.path.abspath(os.path.join(mlbinpath, '..'))) + return self.find_version(os.path.abspath(os.path.join(mlbinpath, '..')), suppress_output) - def env_not_set(self): + def env_not_set(self, suppress_output=False): # Determines if the environment variables required by the MCR are set if self.path_var not in os.environ: return True rt = os.path.join('runtime', self.arch) pv = os.getenv(self.path_var).split(self.sep) for path in [dd for dd in pv if rt in dd]: - if self.find_version(os.path.join(path,'..','..')) is not None: + if self.find_version(os.path.join((path,) + self.dirlevel), suppress_output) is not None: return False return True @@ -262,7 +265,7 @@ def set_environment(self, mlPath=None): return None -def checkPath(runtime_version, mlPath=None, error_if_not_found=True): +def checkPath(runtime_version, mlPath=None, error_if_not_found=True, suppress_output=False): """ Sets the environmental variables for Win, Mac, Linux @@ -279,7 +282,9 @@ def checkPath(runtime_version, mlPath=None, error_if_not_found=True): if not os.path.exists(mlPath): raise FileNotFoundError(f'Input Matlab folder {mlPath} not found') else: - mlPath = obj.guess_from_env() + mlPath = get_matlab_from_registry(runtime_version) if platform.system() == 'Windows' else None + if mlPath is None: + mlPath = obj.guess_from_env() if mlPath is None: mlPath = obj.guess_from_syspath() if mlPath is None: From 14f71c55bb7440d1395c0110078bb3b28e2e621f Mon Sep 17 00:00:00 2001 From: Duc Le Date: Mon, 27 May 2024 14:53:08 +0100 Subject: [PATCH 3/8] Add error message to install MCR toolbox in checkPath --- libpymcr/utils.py | 73 ++++++++++++++++++++++++++++------------------- 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/libpymcr/utils.py b/libpymcr/utils.py index 58d4b3e..e17b018 100644 --- a/libpymcr/utils.py +++ b/libpymcr/utils.py @@ -26,6 +26,8 @@ MLVERDIC = {f'R{rv[0]}{rv[1]}':f'9.{vr}' for rv, vr in zip([[yr, ab] for yr in range(2017,2023) for ab in ['a', 'b']], range(2, 14))} MLVERDIC.update({'R2023a':'9.14', 'R2023b':'23.2'}) +MLEXEFOUND = {} + def get_nret_from_dis(frame): # Tries to get the number of return values for a function # Code adapted from Mantid/Framework/PythonInterface/mantid/kernel/funcinspect.py @@ -136,13 +138,13 @@ def get_matlab_from_registry(version=None): for v in versions: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f'SOFTWARE\\MathWorks\\{installation}\\{v}') as key: retval.append(winreg.QueryValueEx(key, 'MATLABROOT')[0]) - return retval[0] + return retval class DetectMatlab(object): def __init__(self, version=None): self.ver = version - self.PLATFORM_DICT = {'Windows': ['PATH', 'dll', '', 'exe', ';'], 'Linux': ['LD_LIBRARY_PATH', 'so', 'libmw', '', ':'], + self.PLATFORM_DICT = {'Windows': ['PATH', 'dll', '', '.exe', ';'], 'Linux': ['LD_LIBRARY_PATH', 'so', 'libmw', '', ':'], 'Darwin': ['DYLD_LIBRARY_PATH', 'dylib', 'libmw', '', ':']} # Note that newer Matlabs are 64-bit only self.ARCH_DICT = {'Windows': {'64bit': 'win64', '32bit': 'pcwin32'}, @@ -158,8 +160,9 @@ def __init__(self, version=None): self.arch = self.ARCH_DICT[self.system][platform.architecture()[0]] self.required_dirs = self.REQ_DIRS[self.system] self.dirlevel = ('..', '..') + self.matlab_exe = ''.join(('matlab', self.exe_ext)) if self.ver is None: - self.file_to_find = ''.join(('matlab', self.exe_ext)) + self.file_to_find = self.matlab_exe self.dirlevel = ('..',) elif self.system == 'Windows': self.file_to_find = ''.join((self.lib_prefix, 'mclmcrrt', self.ver.replace('.','_'), '.', self.ext)) @@ -178,6 +181,12 @@ def ver(self, val): if self._ver.startswith('R') and self._ver in MLVERDIC.keys(): self._ver = MLVERDIC[self._ver] + def _append_exe(self, exe_file): + if exe_file is not None: + global MLEXEFOUND + if self.ver not in MLEXEFOUND: + MLEXEFOUND[self.ver] = os.path.abspath(Path(exe_file).parents[1]) + def find_version(self, root_dir, suppress_output=False): if not suppress_output: print(f'Searching for Matlab {self.ver} in {root_dir}') @@ -203,6 +212,7 @@ def find_file(path, filename, max_depth=3): print(f'Found Matlab {self.ver} {self.arch} at {ml_path}') return ml_path else: + self._append_exe(find_file(root_dir, self.matlab_exe)) return None def guess_path(self, mlPath=[], suppress_output=False): @@ -228,7 +238,8 @@ def guess_path(self, mlPath=[], suppress_output=False): def guess_from_env(self, ld_path=None): if ld_path is None: ld_path = os.getenv(self.path_var) - if ld_path is None: return None + if ld_path is None: + return None for possible_dir in ld_path.split(self.sep): if os.path.exists(os.path.join(possible_dir, self.file_to_find)): return os.path.abspath(os.path.join((possible_dir,) + self.dirlevel)) @@ -241,28 +252,28 @@ def guess_from_syspath(self, suppress_output=False): mlbinpath = os.path.dirname(os.path.realpath(matlab_exe)) return self.find_version(os.path.abspath(os.path.join(mlbinpath, '..')), suppress_output) - def env_not_set(self, suppress_output=False): - # Determines if the environment variables required by the MCR are set - if self.path_var not in os.environ: - return True - rt = os.path.join('runtime', self.arch) - pv = os.getenv(self.path_var).split(self.sep) - for path in [dd for dd in pv if rt in dd]: - if self.find_version(os.path.join((path,) + self.dirlevel), suppress_output) is not None: - return False - return True + #def env_not_set(self, suppress_output=False): + # # Determines if the environment variables required by the MCR are set + # if self.path_var not in os.environ: + # return True + # rt = os.path.join('runtime', self.arch) + # pv = os.getenv(self.path_var).split(self.sep) + # for path in [dd for dd in pv if rt in dd]: + # if self.find_version(os.path.join((path,) + self.dirlevel), suppress_output) is not None: + # return False + # return True - def set_environment(self, mlPath=None): - if mlPath is None: - mlPath = self.guess_path() - if mlPath is None: - raise RuntimeError('Could not find Matlab') - req_matlab_dirs = self.sep.join([os.path.join(mlPath, sub, self.arch) for sub in self.required_dirs]) - if self.path_var not in os.environ: - os.environ[self.path_var] = req_matlab_dirs - else: - os.environ[self.path_var] += self.sep + req_matlab_dirs - return None + #def set_environment(self, mlPath=None): + # if mlPath is None: + # mlPath = self.guess_path() + # if mlPath is None: + # raise RuntimeError('Could not find Matlab') + # req_matlab_dirs = self.sep.join([os.path.join(mlPath, sub, self.arch) for sub in self.required_dirs]) + # if self.path_var not in os.environ: + # os.environ[self.path_var] = req_matlab_dirs + # else: + # os.environ[self.path_var] += self.sep + req_matlab_dirs + # return None def checkPath(runtime_version, mlPath=None, error_if_not_found=True, suppress_output=False): @@ -282,9 +293,7 @@ def checkPath(runtime_version, mlPath=None, error_if_not_found=True, suppress_ou if not os.path.exists(mlPath): raise FileNotFoundError(f'Input Matlab folder {mlPath} not found') else: - mlPath = get_matlab_from_registry(runtime_version) if platform.system() == 'Windows' else None - if mlPath is None: - mlPath = obj.guess_from_env() + mlPath = obj.guess_from_env() if mlPath is None: mlPath = obj.guess_from_syspath() if mlPath is None: @@ -294,7 +303,13 @@ def checkPath(runtime_version, mlPath=None, error_if_not_found=True, suppress_ou os.environ[obj.path_var] = ld_path #print('Set ' + os.environ.get(obj.path_var)) elif error_if_not_found: - raise RuntimeError('Cannot find Matlab') + if obj.ver in MLEXEFOUND: + raise RuntimeError(f'Found Matlab executable for version {runtime_version} ' \ + 'but could not find Compiler Runtime libraries.\n' \ + 'Please install the Matlab Compiler Runtime SDK toolbox ' \ + 'for this version of Matlab') + else: + raise RuntimeError(f'Cannot find Matlab version {runtime_version}') #else: # print('Found: ' + os.environ.get(obj.path_var)) From b7a79602e6b29ce6c8bd5362aa281103b9784ddc Mon Sep 17 00:00:00 2001 From: Duc Le Date: Mon, 27 May 2024 16:53:07 +0100 Subject: [PATCH 4/8] Add wrapper generation scripts --- libpymcr/Matlab.py | 2 +- libpymcr/utils.py | 15 +- scripts/matlab2python.py | 350 +++++++++++++++++++++++++++++++++++++++ scripts/parseclass.m | 32 ++++ setup.py | 2 + 5 files changed, 394 insertions(+), 7 deletions(-) create mode 100644 scripts/matlab2python.py create mode 100644 scripts/parseclass.m diff --git a/libpymcr/Matlab.py b/libpymcr/Matlab.py index bd7487f..570cce3 100644 --- a/libpymcr/Matlab.py +++ b/libpymcr/Matlab.py @@ -53,7 +53,7 @@ def __getattr__(self, name): class NamespaceWrapper(object): def __init__(self, interface, name): self._interface = interface - self._name = name + self._name = name[:-1] if name.endswith('_') else name def __getattr__(self, name): return NamespaceWrapper(self._interface, f'{self._name}.{name}') diff --git a/libpymcr/utils.py b/libpymcr/utils.py index e17b018..5b889a5 100644 --- a/libpymcr/utils.py +++ b/libpymcr/utils.py @@ -177,9 +177,12 @@ def ver(self): @ver.setter def ver(self, val): - self._ver = str(val) - if self._ver.startswith('R') and self._ver in MLVERDIC.keys(): - self._ver = MLVERDIC[self._ver] + if val is None: + self._ver = None + else: + self._ver = str(val) + if self._ver.startswith('R') and self._ver in MLVERDIC.keys(): + self._ver = MLVERDIC[self._ver] def _append_exe(self, exe_file): if exe_file is not None: @@ -227,7 +230,7 @@ def guess_path(self, mlPath=[], suppress_output=False): if self.system == 'Windows' and ':' not in ml_env: pp = ml_env.split('/')[1:] ml_env = pp[0] + ':\\' + '\\'.join(pp[1:]) - mlPath += [os.path.abspath(os.path.join((ml_env,) + self.dirlevel))] + mlPath += [os.path.abspath(os.path.join(ml_env, *self.dirlevel))] for possible_dir in mlPath + GUESSES[self.system]: if os.path.isdir(possible_dir): rv = self.find_version(possible_dir, suppress_output) @@ -242,7 +245,7 @@ def guess_from_env(self, ld_path=None): return None for possible_dir in ld_path.split(self.sep): if os.path.exists(os.path.join(possible_dir, self.file_to_find)): - return os.path.abspath(os.path.join((possible_dir,) + self.dirlevel)) + return os.path.abspath(os.path.join(possible_dir, *self.dirlevel)) return None def guess_from_syspath(self, suppress_output=False): @@ -259,7 +262,7 @@ def guess_from_syspath(self, suppress_output=False): # rt = os.path.join('runtime', self.arch) # pv = os.getenv(self.path_var).split(self.sep) # for path in [dd for dd in pv if rt in dd]: - # if self.find_version(os.path.join((path,) + self.dirlevel), suppress_output) is not None: + # if self.find_version(os.path.join(path, *self.dirlevel), suppress_output) is not None: # return False # return True diff --git a/scripts/matlab2python.py b/scripts/matlab2python.py new file mode 100644 index 0000000..3fff914 --- /dev/null +++ b/scripts/matlab2python.py @@ -0,0 +1,350 @@ +import sys, os, re +import argparse +import platform +import glob +import tempfile +import subprocess +import json + + +OS = platform.system() +if OS == 'Windows': + EXE, OSSEP = ('.exe', '\\') +else: + EXE, OSSEP = ('', '/') + +RESERVED = ['False', 'await', 'else', 'import', 'pass', 'None', 'break', 'except', 'in', 'raise', 'True', 'class', \ + 'finally', 'is', 'return', 'and', 'continue', 'for', 'lambda', 'try', 'as', 'def', 'from', 'nonlocal', \ + 'while', 'assert', 'del', 'global', 'not', 'with', 'async', 'elif', 'if', 'or', 'yield'] + +def _get_args(): + parser = argparse.ArgumentParser(description='A script to generate Python wrappers for Matlab functions/classes', + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument('-a', metavar='mfolder', action='append', help='Add a folder of Matlab files to be parsed') + parser.add_argument('--preamble', default='import libpymcr; m = libpymcr.Matlab()', help='Premable string to initialize libpymcr') + parser.add_argument('--prefix', default='m', help='Prefix for Matlab commands') + parser.add_argument('--package', default='matlab_functions', help='Name of Python package to put wrappers into') + parser.add_argument('--matlabexec', default=argparse.SUPPRESS, metavar='MLPATH', + help='Full path to Matlab executable (default: autodetect)') + parser.add_argument('-o', '--outputdir', default='matlab_wrapped', help='Output folder for Python files') + parser.add_argument('mfilename', nargs='*', help='Add a Matlab m-file to be parsed') + return parser + + +def _parse_addedfolders(folder_list): + # Parses added folders list to get a list of files and "@" folders + if not folder_list: + return [], [] + from os.path import isfile, isdir + mfiles = [] + classdirs = [] + for folder in folder_list: + mfiles += list(filter(lambda x: isfile(x) and '\@' not in x, glob.glob(f'{folder}/**/*.m', recursive=True))) + classdirs += list(filter(isdir, glob.glob(f'{folder}/**/\@*', recursive=True))) + return mfiles, classdirs + + +def _parse_mfuncstring(funcstring): + # Parses the function header to get input and output variable names + funcstring = funcstring.replace('...', '') + if '(' in funcstring: + if '=' in funcstring: + m = re.search('function ([\[\]A-z0-9_,\s]*)=([A-z0-9_\s]*)\(([A-z0-9_,\s~]*)\)', funcstring) + outvars = [v.strip() for v in m.group(1).replace('[','').replace(']','').split(',')] + else: + outvars = None + m = re.search('function ()([A-z0-9_\s]*)\(([A-z0-9_,\s~]*)\)', funcstring) + invars = [v.strip() for v in m.group(3).strip().split(',')] + else: + invars = None + if '=' in funcstring: + m = re.search('function ([\[\]A-z0-9_,\s]*)=([A-z0-9_\s]*)', funcstring) + outvars = [v.strip() for v in m.group(1).replace('[','').replace(']','').split(',')] + else: + outvars = None + return invars, outvars + + +def _parse_single_mfile(mfile): + # Parses a single m-file to check if it is a function, classdef or script + with open(mfile, 'r') as f: + inblockcomment = False + predocstring, postdocstring = ([], []) + funcstring = [] + outside_funcdef = False + for line in f: + line = line.strip() + if line.startswith('%{'): + inblockcomment = True + if not inblockcomment: + if outside_funcdef: + if line.startswith('%'): + postdocstring.append(line) + else: + break + if not outside_funcdef and len(funcstring) > 0: + funcstring.append(line) + if ')' in line: + funcstring = ''.join(funcstring) + outside_funcdef = True + elif line.startswith('function'): + if ')' in line or '(' not in line: + funcstring = line + outside_funcdef = True + else: + funcstring = [line] + elif line.startswith('classdef'): + return 'classdef', [mfile] + elif not line.startswith('%'): + # If the line does not start with a comment we are in the body + break + else: + predocstring.append(line) + elif line.startswith('%}'): + inblockcomment = False + if len(funcstring) > 0: + docstring = predocstring if len(predocstring) > 0 else postdocstring + return 'function', [[mfile, _parse_mfuncstring(funcstring), '\n'.join(docstring)]] + return 'script', [] + + +def _parse_mfilelist(mfile_list): + # Parses a list of mfiles to see if they are functions, classdefs or scripts + typelist = {'function':[], 'classdef':[], 'script':[]} + for mfile in mfile_list: + filetype, fileinfo = _parse_single_mfile(mfile) + typelist[filetype] += fileinfo + return typelist['function'], typelist['classdef'] + + +def _conv_path_to_matlabpack(pth): + pth = pth.replace('.m', '') + if '+' in pth: + pth = ''.join(pth.split('+')[1:]).replace(os.path.sep, '.') + else: + pth = os.path.split(pth)[1] + return pth + + +def _matlab_parse_classes(classdirs, classfiles, mlexec, addeddirs, addedfiles): + # Uses Matlab metaclass to parse classes + dirs = set([os.path.dirname(f) for f in addedfiles]) + dirs = list(filter(lambda x: not any([x.startswith(d) for d in addeddirs]), dirs)) + # Strips out package directories + dirs = list(set([d.split('+')[0] for d in dirs])) + # Parses the class list + classlist = [] + for cls in classdirs + classfiles: + cls = _conv_path_to_matlabpack(cls) + cls = cls.replace('@', '') + classlist.append(cls) + with tempfile.TemporaryDirectory() as d_name: + parsemfile = os.path.join(d_name, 'parsescript.m') + jsonout = os.path.join(d_name, 'classdata.json') + with open(parsemfile, 'w') as f: + f.write(f"addpath('{os.path.dirname(__file__)}');\n") + for folder in addeddirs: + f.write(f"addpath(genpath('{folder}'));\n") + for folder in dirs: + f.write(f"addpath('{folder}');\n") + idx = 1 + for classname in classlist: + f.write(f"outstruct({idx}) = parseclass('{classname}');\n") + idx += 1 + f.write("jsontxt = jsonencode(outstruct);\n") + f.write(f"fid = fopen('{jsonout}', 'w');\n") + f.write("fprintf(fid, '%s', jsontxt);\n") + f.write("fclose(fid);\n") + mlcmd = [mlexec, '-batch', f"addpath('{d_name}'); parsescript; exit"] + res = subprocess.run(mlcmd, capture_output=True) + if res.returncode != 0: + raise RuntimeError(f'Matlab parsing of classes failed with error:\n{res.stdout}\n{res:stderr}') + with open(jsonout, 'r') as f: + classinfo = json.load(f) + return classinfo + + +def _python_parse_classes(classdirs, classfiles): + raise NotImplementedError('Python parsing of classes not implemented') + + +def _parse_args(fn): + if fn[1][0] is None or (len(fn[1][0]) == 1 and fn[1][0][0] == ''): + return '', 'args = []' + if len(fn[1][0]) == 1 and fn[1][0][0] == 'varargin': + return '*args, ', '' + argp = ', '.join([v for v in fn[1][0] if v != '' and 'varargin' not in v and v != '~']) + args = ''.join([f'{v.replace("~","_")}=None, ' for v in fn[1][0] if v!= '']).replace('varargin=None, ', '*args, ') + argline = f'args = [v for v in [{argp}] if v is not None]' + if '*args' in args: + argline += ' + args' + return args, argline + + +def _get_funcname(fname): + # Checks Python functions against reserved keywords + return fname + '_' if fname in RESERVED else fname + + +def _write_function(f, fn, prefix): + args, argline = _parse_args(fn) + fnname = _get_funcname(fn[0]) + f.write(f'def {fnname}({args}**kwargs):\n') + f.write(f' """\n') + f.write(f'{fn[2]}\n') + f.write(f' """\n') + f.write(f' {argline}\n') + f.write(f' return {prefix}.{fnname}(*args, **kwargs)\n\n\n') + + +def _generate_wrappers(funcfiles, classinfo, outputdir, preamble, prefix): + # Generates Python wrappers from Matlab function and class info + # + # Puts all the functions into output/__init__.py and all classes into their own files, + # preserving Matlab directory/package structures + preamble = re.sub(';\s*', '\n', preamble) + '\n\n' + if not os.path.exists(outputdir): + os.mkdir(outputdir) + elif not os.path.isdir(outputdir): + raise RuntimeError(f'Output {outputdir} is not a folder') + classes = [] + class_in_packages = {} + for cls in classinfo: + if '.' in cls['name']: + package = os.path.join(*cls['name'].split('.')[:-1]) + clsname = cls['name'].split('.')[-1] + if package in class_in_packages: + class_in_packages[package].append(clsname) + else: + class_in_packages[package] = [clsname] + classfile = os.path.join(outputdir, package, f"{clsname}.py") + packdir = os.path.join(outputdir, package) + if not os.path.exists(packdir): + os.makedirs(packdir) + else: + classes.append(cls['name']) + classfile = os.path.join(outputdir, f"{cls['name']}.py") + with open(classfile, 'w') as f: + f.write(preamble) + f.write("from libpymcr import MatlabProxyObject\n") + f.write("from libpymcr.utils import get_nlhs\n\n") + f.write(f"class {_get_funcname(cls['name'])}(MatlabProxyObject):\n") + f.write(' """\n') + f.write(f"{cls['doc']}\n") + f.write(' """\n') + clscons = [c for c in cls['methods'] if c['name'] == cls['name']] + if clscons: + clscons = clscons[0] + args, argline = _parse_args([[], [clscons['inputs'], []], []]) + f.write(f" def __init__(self, {args}**kwargs):\n") + f.write( ' """\n') + f.write(f"{clscons['doc']}\n") + f.write( ' """\n') + f.write(f" self.__dict__['interface'] = {prefix}._interface\n") + f.write( " self.__dict__['_methods'] = []\n") + f.write(f' {argline}\n') + f.write(f' args += sum(kwargs.items(), ())\n') + f.write(f" self.__dict__['handle'] = self.interface.call('{cls['name']}', *args, nargout=1)\n") + f.write( '\n') + else: + f.write(f" def __init__(self):\n") + f.write(f" self.__dict__['interface'] = {prefix}._interface\n") + f.write( " self.__dict__['methods'] = []\n") + f.write(f" self.__dict__['handle'] = self.interface.call('{cls['name']}', [], nargout=1)\n") + f.write( '\n') + # Create a "help" method so it doesn't try to call the Matlab (and so crash) + if not isinstance(cls['properties'], list): + cls['properties'] = [cls['properties']] + for prop in cls['properties']: + propname = _get_funcname(prop['name']) + f.write( ' @property\n') + f.write(f" def {propname}(self):\n") + f.write( ' """\n') + f.write(f"{prop['doc']}\n") + f.write( ' """\n') + f.write(f" return self.__getattr__('{prop['name']}')\n") + f.write( '\n') + f.write(f" @{propname}.setter\n") + f.write(f" def {propname}(self, val):\n") + f.write(f" self.__setattr__('{prop['name']}', val)\n") + f.write( '\n') + for mth in cls['methods']: + if mth['name'] == cls['name']: + continue + args, argline = _parse_args([[], [mth['inputs'], []], []]) + mthname = _get_funcname(mth['name']) + f.write(f" def {mthname}(self, {args}**kwargs):\n") + f.write( ' """\n') + f.write(f"{mth['doc']}\n") + f.write( ' """\n') + f.write(f' {argline}\n') + f.write(f' args += sum(kwargs.items(), ())\n') + f.write(f" nout = max(min({len(mth['outputs'])}, get_nlhs('{mthname}')), 1)\n") + f.write(f" return {prefix}.{mthname}(self.handle, *args, nargout=nout)\n") + f.write( '\n') + funcs_in_packages = {} + with open(os.path.join(outputdir, '__init__.py'), 'w') as f: + f.write(preamble) + for cls in classes: + f.write(f"from .{cls} import {cls}\n") + f.write('\n') + for fn in funcfiles: + fn[0] = _conv_path_to_matlabpack(fn[0]) + if '.' in fn[0]: # Put packages into separate files + package = os.path.join(*fn[0].split('.')[:-1]) + if package in funcs_in_packages: + funcs_in_packages[package].append(fn) + else: + funcs_in_packages[package] = [fn] + else: + # Assume that varargin if present is always the last argument + _write_function(f, fn, prefix) + for pack, fns in funcs_in_packages.items(): + packdir = os.path.join(outputdir, pack) + if not os.path.exists(packdir): + os.makedirs(packdir) + with open(os.path.join(packdir, '__init__.py'), 'w') as f: + f.write(preamble) + if pack in class_in_packages: + for cls in class_in_packages.pop(pack): + f.write(f"from .{cls} import {cls}\n") + f.write('\n') + for fn in fns: + _write_function(f, fn, prefix) + for pack, clss in class_in_packages.items(): + packdir = os.path.join(outputdir, pack) + with open(os.path.join(packdir, '__init__.py'), 'w') as f: + for cls in clss: + f.write(f"from .{cls} import {cls}\n") + + +def main(args=None): + args = _get_args().parse_args(args if args else sys.argv[1:]) + if args.a is None and len(args.mfilename) == 0: + print('No mfiles or folders specified. Exiting') + return + # Checks if we have Matlab installed + if hasattr(args, 'matlabexec'): + if os.path.isfile(args.matlabexec): + mlexec = args.matlabexec + else: + raise RuntimeError(f'Matlab executable "{args.matlabexec}" does not exist or is not a file') + else: + import libpymcr.utils + mlpath = libpymcr.utils.checkPath(runtime_version=None) + mlexec = os.path.join(mlpath, 'bin', f'matlab{EXE}') if mlpath else None + + mfiles, classdirs = _parse_addedfolders(args.a) + funcfiles, classfiles = _parse_mfilelist(mfiles + args.mfilename) + + if mlexec: + # Use Matlab to parse classes. + classinfo = _matlab_parse_classes(classdirs, classfiles, mlexec, args.a, classfiles) + else: + classinfo = _python_parse_classes(classdirs, classfiles) + + _generate_wrappers(funcfiles, classinfo, args.outputdir, args.preamble, args.prefix) + + +if __name__ == '__main__': + main() diff --git a/scripts/parseclass.m b/scripts/parseclass.m new file mode 100644 index 0000000..6357d8f --- /dev/null +++ b/scripts/parseclass.m @@ -0,0 +1,32 @@ +function outstruct = parseclass(classname) + if classname(1) == '@' + classname = classname(2:end); + end + if classname(end-1:end) == '.m' + classname = classname(1:end-2); + end + if verLessThan('Matlab', '24.1') + classinfo = meta.class.fromName(classname); + else + classinfo = matlab.metadata.Class.fromName(classname); + end + % Ignore handle methods + handle_methods = {'addlistener', 'delete', 'empty', 'eq', 'findobj', ... + 'findprop', 'ge', 'gt', 'isvalid', 'le', 'listener', 'lt', 'ne', 'notify'}; + [class_methods{1:numel(classinfo.MethodList)}] = deal(classinfo.MethodList.Name); + [~, icm] = setdiff(class_methods, handle_methods); + for ii = 1:numel(icm) + methodobj = classinfo.MethodList(icm(ii)); + out_methods(ii) = struct('name', methodobj.Name, ... + 'inputs', {methodobj.InputNames}, ... + 'outputs', {methodobj.OutputNames}, ... + 'doc', evalc(['help ' classname '/' methodobj.Name])); + end + nonhidden = arrayfun(@(x) ~x.Hidden, classinfo.PropertyList); + out_props = arrayfun(@(x) struct('name', x.Name, ... + 'doc', evalc(['help ' classname '/' x.Name])), classinfo.PropertyList(nonhidden)); + outstruct = struct('name', classname, ... + 'methods', out_methods(:), ... + 'properties', out_props(:), ... + 'doc', evalc(['help ' classname])); +end diff --git a/setup.py b/setup.py index 4ca6d8a..540a03d 100644 --- a/setup.py +++ b/setup.py @@ -142,6 +142,8 @@ def build_extension(self, ext): packages=['libpymcr'], install_requires = ['numpy>=1.7.1'], cmdclass=cmdclass, + entry_points = {'console_scripts': [ + 'matlab2python = scripts.matlab2python:main']}, url="https://github.com/pace-neutrons/libpymcr", zip_safe=False, classifiers=[ From 52800ab410301faaa6b6877534765be60ddd03b8 Mon Sep 17 00:00:00 2001 From: Duc Le Date: Mon, 27 May 2024 18:01:24 +0100 Subject: [PATCH 5/8] Fix wrapper script so pydoc help works --- libpymcr/MatlabProxyObject.py | 8 ++++++++ scripts/matlab2python.py | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/libpymcr/MatlabProxyObject.py b/libpymcr/MatlabProxyObject.py index a951fb5..1e68209 100644 --- a/libpymcr/MatlabProxyObject.py +++ b/libpymcr/MatlabProxyObject.py @@ -67,6 +67,14 @@ def __repr__(self): rv += f" {k}: {v}\n" return rv + @property + def __name__(self): + return self.name + + @property + def __origin__(self): + return getattr(type(self.parent), self.name) + class matlab_method: def __init__(self, proxy, method): diff --git a/scripts/matlab2python.py b/scripts/matlab2python.py index 3fff914..ec4f953 100644 --- a/scripts/matlab2python.py +++ b/scripts/matlab2python.py @@ -225,10 +225,11 @@ def _generate_wrappers(funcfiles, classinfo, outputdir, preamble, prefix): classes.append(cls['name']) classfile = os.path.join(outputdir, f"{cls['name']}.py") with open(classfile, 'w') as f: + clsname = _get_funcname(cls['name']) f.write(preamble) f.write("from libpymcr import MatlabProxyObject\n") f.write("from libpymcr.utils import get_nlhs\n\n") - f.write(f"class {_get_funcname(cls['name'])}(MatlabProxyObject):\n") + f.write(f"class {clsname}(MatlabProxyObject):\n") f.write(' """\n') f.write(f"{cls['doc']}\n") f.write(' """\n') @@ -242,6 +243,8 @@ def _generate_wrappers(funcfiles, classinfo, outputdir, preamble, prefix): f.write( ' """\n') f.write(f" self.__dict__['interface'] = {prefix}._interface\n") f.write( " self.__dict__['_methods'] = []\n") + f.write(f" self.__dict__['__name__'] = '{clsname}'\n") # Needed for pydoc + f.write(f" self.__dict__['__origin__'] = {clsname}\n") # Needed for pydoc f.write(f' {argline}\n') f.write(f' args += sum(kwargs.items(), ())\n') f.write(f" self.__dict__['handle'] = self.interface.call('{cls['name']}', *args, nargout=1)\n") From 4007d5cc4448836784de70c90c59f1bf7a40fe10 Mon Sep 17 00:00:00 2001 From: Duc Le Date: Wed, 29 May 2024 05:01:01 +0100 Subject: [PATCH 6/8] Bugfix applying to SpinW and Horace Move wrapper script matlab2python into libpymcr namespace Fix packaging of matlab2python Fix bug in wrapper argument parsing (convert to tuple) MatlabProxyObject call() dunder methods needs wrapping Convert subsref access ref to use Py-tuple to force mat-cell Fix call.m nargout bug for function name collision ("fit") --- libpymcr/MatlabProxyObject.py | 42 +++++++++---------- .../scripts}/matlab2python.py | 2 +- {scripts => libpymcr/scripts}/parseclass.m | 0 libpymcr/utils.py | 8 +--- setup.py | 5 ++- src/call.m | 18 ++++---- src/type_converter.cpp | 2 +- 7 files changed, 34 insertions(+), 43 deletions(-) rename {scripts => libpymcr/scripts}/matlab2python.py (99%) rename {scripts => libpymcr/scripts}/parseclass.m (100%) diff --git a/libpymcr/MatlabProxyObject.py b/libpymcr/MatlabProxyObject.py index 1e68209..acd7991 100644 --- a/libpymcr/MatlabProxyObject.py +++ b/libpymcr/MatlabProxyObject.py @@ -25,7 +25,7 @@ def unwrap(inputs, interface): return interface.call('feval', meth_wrapper, inputs.proxy.handle) elif isinstance(inputs, tuple): return tuple(unwrap(v, interface) for v in inputs) - elif isinstance(inputs, list): + elif isinstance(inputs, list) or isinstance(inputs, range): return [unwrap(v, interface) for v in inputs] elif isinstance(inputs, dict): return {k:unwrap(v, interface) for k, v in inputs.items()} @@ -186,38 +186,38 @@ def __dir__(self): def __getitem__(self, key): if not (isinstance(key, int) or (hasattr(key, 'is_integer') and key.is_integer())) or key < 0: raise RuntimeError('Matlab container indices must be positive integers') - key = [float(key + 1)] # Matlab uses 1-based indexing - return self.interface.call('subsref', self.handle, {'type':'()', 'subs':key}) + key = (float(key + 1),) # Matlab uses 1-based indexing + return wrap(self.interface.call('subsref', self.handle, {'type':'()', 'subs':key}), self.interface) def __setitem__(self, key, value): if not (isinstance(key, int) or (hasattr(key, 'is_integer') and key.is_integer())) or key < 0: raise RuntimeError('Matlab container indices must be positive integers') if not isinstance(value, MatlabProxyObject) or repr(value) != self.__repr__(): raise RuntimeError('Matlab container items must be same type.') - access = self.interface.call('substruct', '()', [float(key + 1)]) # Matlab uses 1-based indexing - self.__dict__['handle'] = self.interface.call('subsasgn', self.handle, access, value) + access = self.interface.call('substruct', '()', (float(key + 1),)) # Matlab uses 1-based indexing + self.__dict__['handle'] = self.interface.call('subsasgn', self.handle, access, value.handle) def __len__(self): return int(self.interface.call('numel', self.handle, nargout=1)) # Operator overloads def __eq__(self, other): - return self.interface.call('eq', self.handle, other, nargout=1) + return self.interface.call('eq', self.handle, unwrap(other, self.interface), nargout=1) def __ne__(self, other): - return self.interface.call('ne', self.handle, other, nargout=1) + return self.interface.call('ne', self.handle, unwrap(other, self.interface), nargout=1) def __lt__(self, other): - return self.interface.call('lt', self.handle, other, nargout=1) + return self.interface.call('lt', self.handle, unwrap(other, self.interface), nargout=1) def __gt__(self, other): - return self.interface.call('gt', self.handle, other, nargout=1) + return self.interface.call('gt', self.handle, unwrap(other, self.interface), nargout=1) def __le__(self, other): - return self.interface.call('le', self.handle, other, nargout=1) + return self.interface.call('le', self.handle, unwrap(other, self.interface), nargout=1) def __ge__(self, other): - return self.interface.call('ge', self.handle, other, nargout=1) + return self.interface.call('ge', self.handle, unwrap(other, self.interface), nargout=1) def __bool__(self): return self.interface.call('logical', self.handle, nargout=1) @@ -226,7 +226,7 @@ def __and__(self, other): # bit-wise & operator (not `and` keyword) return self.interface.call('and', self.handle, other, nargout=1) def __or__(self, other): # bit-wise | operator (not `or` keyword) - return self.interface.call('or', self.handle, other, nargout=1) + return self.interface.call('or', self.handle, unwrap(other, self.interface), nargout=1) def __invert__(self): # bit-wise ~ operator (not `not` keyword) return self.interface.call('not', self.handle, nargout=1) @@ -241,31 +241,31 @@ def __abs__(self): return self.interface.call('abs', self.handle, nargout=1) def __add__(self, other): - return self.interface.call('plus', self.handle, other, nargout=1) + return self.interface.call('plus', self.handle, unwrap(other, self.interface), nargout=1) def __radd__(self, other): - return self.interface.call('plus', other, self.handle, nargout=1) + return self.interface.call('plus', unwrap(other, self.interface), self.handle, nargout=1) def __sub__(self, other): - return self.interface.call('minus', self.handle, other, nargout=1) + return self.interface.call('minus', self.handle, unwrap(other, self.interface), nargout=1) def __rsub__(self, other): - return self.interface.call('minus', other, self.handle, nargout=1) + return self.interface.call('minus', unwrap(other, self.interface), self.handle, nargout=1) def __mul__(self, other): - return self.interface.call('mtimes', self.handle, other, nargout=1) + return self.interface.call('mtimes', self.handle, unwrap(other, self.interface), nargout=1) def __rmul__(self, other): - return self.interface.call('mtimes', other, self.handle, nargout=1) + return self.interface.call('mtimes', unwrap(other, self.interface), self.handle, nargout=1) def __truediv__(self, other): - return self.interface.call('mrdivide', self.handle, other, nargout=1) + return self.interface.call('mrdivide', self.handle, unwrap(other, self.interface), nargout=1) def __rtruediv__(self, other): - return self.interface.call('mrdivide', other, self.handle, nargout=1) + return self.interface.call('mrdivide', unwrap(other, self.interface), self.handle, nargout=1) def __pow__(self, other): - return self.interface.call('mpower', self.handle, other, nargout=1) + return self.interface.call('mpower', self.handle, unwrap(other, self.interface), nargout=1) @property def __doc__(self): diff --git a/scripts/matlab2python.py b/libpymcr/scripts/matlab2python.py similarity index 99% rename from scripts/matlab2python.py rename to libpymcr/scripts/matlab2python.py index ec4f953..83175ff 100644 --- a/scripts/matlab2python.py +++ b/libpymcr/scripts/matlab2python.py @@ -175,7 +175,7 @@ def _parse_args(fn): return '*args, ', '' argp = ', '.join([v for v in fn[1][0] if v != '' and 'varargin' not in v and v != '~']) args = ''.join([f'{v.replace("~","_")}=None, ' for v in fn[1][0] if v!= '']).replace('varargin=None, ', '*args, ') - argline = f'args = [v for v in [{argp}] if v is not None]' + argline = f'args = tuple(v for v in [{argp}] if v is not None)' if '*args' in args: argline += ' + args' return args, argline diff --git a/scripts/parseclass.m b/libpymcr/scripts/parseclass.m similarity index 100% rename from scripts/parseclass.m rename to libpymcr/scripts/parseclass.m diff --git a/libpymcr/utils.py b/libpymcr/utils.py index 5b889a5..9363efc 100644 --- a/libpymcr/utils.py +++ b/libpymcr/utils.py @@ -204,13 +204,7 @@ def find_file(path, filename, max_depth=3): return None lib_file = find_file(root_dir, self.file_to_find) if lib_file is not None: - lib_path = Path(lib_file) - arch_dir = lib_path.parts[-2] - self.arch = arch_dir - ml_subdir = lib_path.parts[-3] - if ml_subdir != 'runtime': - self.ver = ml_subdir - ml_path = os.path.abspath(lib_path.parents[2]) + ml_path = os.path.abspath(os.path.join(os.path.dirname(lib_file), *self.dirlevel)) if not suppress_output: print(f'Found Matlab {self.ver} {self.arch} at {ml_path}') return ml_path diff --git a/setup.py b/setup.py index 540a03d..87c29dd 100644 --- a/setup.py +++ b/setup.py @@ -139,11 +139,12 @@ def build_extension(self, ext): long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", ext_modules=[CMakeExtension('libpymcr._libpymcr')], - packages=['libpymcr'], + packages=['libpymcr', 'libpymcr.scripts'], + package_data={'libpymcr.scripts': [os.path.join(os.path.dirname(__file__), 'libpymcr', 'scripts', 'parseclass.m')]}, install_requires = ['numpy>=1.7.1'], cmdclass=cmdclass, entry_points = {'console_scripts': [ - 'matlab2python = scripts.matlab2python:main']}, + 'matlab2python = libpymcr.scripts.matlab2python:main']}, url="https://github.com/pace-neutrons/libpymcr", zip_safe=False, classifiers=[ diff --git a/src/call.m b/src/call.m index 60f69f2..f5e27c4 100644 --- a/src/call.m +++ b/src/call.m @@ -4,17 +4,6 @@ return end resultsize = nargout; - try - maxresultsize = nargout(name); - if maxresultsize == -1 - maxresultsize = resultsize; - end - catch - maxresultsize = resultsize; - end - if resultsize > maxresultsize - resultsize = maxresultsize; - end if nargin == 1 args = {}; else @@ -32,6 +21,13 @@ catch err if (strcmp(err.identifier,'MATLAB:unassignedOutputs')) varargout = eval_ans(name, args); + elseif strcmp(err.identifier,'MATLAB:TooManyOutputs') + try + maxresultsize = max([nargout(name), 0]); + catch + maxresultsize = 1; % Default to one output + end + [varargout{1:maxresultsize}] = feval(name, args{:}); else rethrow(err); end diff --git a/src/type_converter.cpp b/src/type_converter.cpp index c4a8782..4a9a65b 100644 --- a/src/type_converter.cpp +++ b/src/type_converter.cpp @@ -553,7 +553,7 @@ matlab::data::Array pymat_converter::python_to_matlab_single(PyObject *input, ma } else if (PyComplex_Check(input)) { output = factory.createScalar(std::complex(PyComplex_RealAsDouble(input), PyComplex_ImagAsDouble(input))); } else if (input == Py_None) { - output = factory.createArray({}); + output = factory.createArray({0,0}); } else if (PyCallable_Check(input)) { output = wrap_python_function(input, factory); } else if (PyObject_TypeCheck(input, m_py_matlab_wrapper_t)) { From 68fabf0d38f467d2369385619c5ba61d1fcccbca Mon Sep 17 00:00:00 2001 From: Duc Le Date: Thu, 30 May 2024 02:46:08 +0100 Subject: [PATCH 7/8] More bugfixes from testing with SpinW Extend DictPropertyWrapper to lists allowing their elements to be set individually Add new VectorPropertyWrapper so np column vectors can be indexed with a single index Fix matlab2python script to properly handle packages Bypass a disassembly bug in get_nlhs Add R2024a to list of versions --- libpymcr/MatlabProxyObject.py | 28 ++++++++++++++++++++++++---- libpymcr/scripts/matlab2python.py | 22 ++++++++++++++++++---- libpymcr/utils.py | 4 +++- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/libpymcr/MatlabProxyObject.py b/libpymcr/MatlabProxyObject.py index acd7991..42bb0f4 100644 --- a/libpymcr/MatlabProxyObject.py +++ b/libpymcr/MatlabProxyObject.py @@ -1,5 +1,6 @@ from io import StringIO from .utils import get_nlhs +import numpy as np import re def wrap(inputs, interface): @@ -33,18 +34,37 @@ def unwrap(inputs, interface): return inputs +class VectorPropertyWrapper: + # A proxy for a Matlab (ndarray) column vector to allow single indexing + def __init__(self, val): + self.val = val + + def __getitem__(self, ind): + return self.val[0, ind] if ind > 0 else self.val[ind] + + def __setitem__(self, ind, value): + if ind > 0: + self.val[0, ind] = value + else: + self.val[ind] = value + + def __repr__(self): + return self.val.__repr__() + + class DictPropertyWrapper: # A proxy for dictionary properties of classes to allow Matlab .dot syntax def __init__(self, val, name, parent): - assert isinstance(val, dict), "DictPropertyWrapper can only wrap dict objects" self.__dict__['val'] = val self.__dict__['name'] = name self.__dict__['parent'] = parent def __getattr__(self, name): rv = self.val[name] - if isinstance(rv, dict): + if isinstance(rv, dict) or isinstance(rv, list): rv = DictPropertyWrapper(rv, name, self) + elif isinstance(rv, np.ndarray) and rv.shape[0] == 1: + rv = VectorPropertyWrapper(rv) return rv def __setattr__(self, name, value): @@ -53,7 +73,7 @@ def __setattr__(self, name, value): def __getitem__(self, name): rv = self.val[name] - if isinstance(rv, dict): + if isinstance(rv, dict) or isinstance(rv, list): rv = DictPropertyWrapper(rv, name, self) return rv @@ -159,7 +179,7 @@ def __getattr__(self, name): if name in self._getAttributeNames(): try: rv = wrap(self.interface.call('subsref', self.handle, {'type':'.', 'subs':name}), self.interface) - if isinstance(rv, dict): + if isinstance(rv, dict) or isinstance(rv, list): rv = DictPropertyWrapper(rv, name, self) return rv except TypeError: diff --git a/libpymcr/scripts/matlab2python.py b/libpymcr/scripts/matlab2python.py index 83175ff..9416eb8 100644 --- a/libpymcr/scripts/matlab2python.py +++ b/libpymcr/scripts/matlab2python.py @@ -183,18 +183,21 @@ def _parse_args(fn): def _get_funcname(fname): # Checks Python functions against reserved keywords + if '.' in fname: + fname = fname.split('.')[-1] return fname + '_' if fname in RESERVED else fname def _write_function(f, fn, prefix): args, argline = _parse_args(fn) fnname = _get_funcname(fn[0]) + mname = fnname if '.' not in fn[0] else fn[0] f.write(f'def {fnname}({args}**kwargs):\n') f.write(f' """\n') f.write(f'{fn[2]}\n') f.write(f' """\n') f.write(f' {argline}\n') - f.write(f' return {prefix}.{fnname}(*args, **kwargs)\n\n\n') + f.write(f' return {prefix}.{mname}(*args, **kwargs)\n\n\n') def _generate_wrappers(funcfiles, classinfo, outputdir, preamble, prefix): @@ -233,6 +236,8 @@ def _generate_wrappers(funcfiles, classinfo, outputdir, preamble, prefix): f.write(' """\n') f.write(f"{cls['doc']}\n") f.write(' """\n') + if not isinstance(cls['methods'], list): + cls['methods'] = [cls['methods']] clscons = [c for c in cls['methods'] if c['name'] == cls['name']] if clscons: clscons = clscons[0] @@ -290,7 +295,7 @@ def _generate_wrappers(funcfiles, classinfo, outputdir, preamble, prefix): f.write(preamble) for cls in classes: f.write(f"from .{cls} import {cls}\n") - f.write('\n') + singlefuncs = [] for fn in funcfiles: fn[0] = _conv_path_to_matlabpack(fn[0]) if '.' in fn[0]: # Put packages into separate files @@ -300,8 +305,15 @@ def _generate_wrappers(funcfiles, classinfo, outputdir, preamble, prefix): else: funcs_in_packages[package] = [fn] else: - # Assume that varargin if present is always the last argument - _write_function(f, fn, prefix) + singlefuncs.append(fn) + packages = list(funcs_in_packages.keys()) + list(class_in_packages.keys()) + # Imports only first level packages in the main __init__.py + for cls in [p for p in packages if os.path.sep not in p]: + f.write(f"from . import {cls}\n") + f.write('\n') + for fn in singlefuncs: + # Assume that varargin if present is always the last argument + _write_function(f, fn, prefix) for pack, fns in funcs_in_packages.items(): packdir = os.path.join(outputdir, pack) if not os.path.exists(packdir): @@ -311,6 +323,8 @@ def _generate_wrappers(funcfiles, classinfo, outputdir, preamble, prefix): if pack in class_in_packages: for cls in class_in_packages.pop(pack): f.write(f"from .{cls} import {cls}\n") + for cls in [p for p in packages if p != pack and p.startswith(pack)]: + f.write(f"from . import {cls.split(pack)[1][1:].split(os.path.sep)[0]}\n") f.write('\n') for fn in fns: _write_function(f, fn, prefix) diff --git a/libpymcr/utils.py b/libpymcr/utils.py index 9363efc..152df68 100644 --- a/libpymcr/utils.py +++ b/libpymcr/utils.py @@ -24,7 +24,7 @@ } MLVERDIC = {f'R{rv[0]}{rv[1]}':f'9.{vr}' for rv, vr in zip([[yr, ab] for yr in range(2017,2023) for ab in ['a', 'b']], range(2, 14))} -MLVERDIC.update({'R2023a':'9.14', 'R2023b':'23.2'}) +MLVERDIC.update({'R2023a':'9.14', 'R2023b':'23.2', 'R2024a':'24.1'}) MLEXEFOUND = {} @@ -43,6 +43,8 @@ def get_nret_from_dis(frame): start_i = index start_offset = ins.offset call_fun_locs[start_offset] = start_i + if last_i not in call_fun_locs: + return 1 # Some error in the disassembly last_fun_offset = call_fun_locs[last_i] last_i_name = ins_stack[last_fun_offset].opname next_i_name = ins_stack[last_fun_offset + 1].opname From db0d233a416a0087acaa42ff2072d148ba156ecf Mon Sep 17 00:00:00 2001 From: Duc Le Date: Thu, 30 May 2024 02:51:25 +0100 Subject: [PATCH 8/8] Update Changelog and citation --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ CITATION.cff | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c59fe6..cbf3764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +# [v0.1.7](https://github.com/pace-neutrons/libpymcr/compare/v0.1.6...v0.1.7) + +## New Features + +Add a command `matlab2python` which generates Python wrappers for Matlab code in a specified directory (syntax is similar to `mcc`). +By default the Python wrappers are created in a package folder called `matlab_wrapped`. +If you have a package and want to call the Matlab functions without the `m.` prefix, you can do: + +``` +from matlab_wrapped import * +``` + +Matlab class properties which are standard types (`dict`s, `list`s and numpy arrays) are now better supported by a fix to `DictPropertyWrapper` and a new `VectorPropertyWrapper`, which allows syntax like: + +``` +obj.prop['a'] = 1 +obj.prop[1] = 2 +``` + +Matlab column vectors can be indexed with a single index (rather than requiring a tuple as in `obj.prop[1,2]` which numpy requires). + +## Bugfixes + +Bugfixes for some issues arising from testing in preparation for EDATC2 + +* Update matlab checker to be more robust and to output a warning of a licensed Matlab was found but the Compiler SDK toolbox is not installed. +* Fix `MatlabProxyObject` dunder methods to work with libpymcr. +* Fix `MatlabProxyObject` indexing bug where libpymcr converted the old list to a vector instead of a cell-array (now uses a tuple). +* Fix a bug in `call.m` where `nargout` is confused if the called function is shadowed (e.g. `fit`) and it could not determined the maximum `nargout`. + # [v0.1.6](https://github.com/pace-neutrons/libpymcr/compare/v0.1.5...v0.1.6) ## Bugfixes diff --git a/CITATION.cff b/CITATION.cff index b6c31d2..5539e75 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -15,7 +15,7 @@ authors: given-names: "Gregory S." orcid: https://orcid.org/0000-0002-2787-8054 title: "libpymcr" -version: "0.1.6" +version: "0.1.7" date-released: "2024-04-26" license: "GPL-3.0-only" repository: "https://github.com/pace-neutrons/libpymcr"