from collections import namedtuple from os.path import join as pjoin from pathlib import Path import os import subprocess from buildutils import * from SCons import Errors Import('env', 'build', 'install') localenv = env.Clone() Page = namedtuple('Page', ['name', 'title', 'objects']) # Set up functions to pseudo-autodoc the MATLAB toolbox def extract_matlab_docstring(mfile, level): """ Return the docstring from mfile, assuming that it consists of the first uninterrupted comment block. :param mfile: File name of the matlab file from which the documentation will be read :param level: Level of documentation. Class = 0, Function = 1 """ # Set the start of the docstring based on the level passed in. This is only # necessary for the old-style MATLAB classes, where each method is its own # file. if level == 0: docstring = ".. mat:class:: " elif level == 1: docstring = " .. mat:function:: " else: logger.error("Unknown level for MATLAB documentation.") sys.exit(1) # The leader is the number of spaces at the beginning of a regular line # of documentation. leader = ' '*4*(level + 1) with open(mfile, 'r') as in_file: # The function name is read from the first line docstring += get_function_name(in_file.readline()) + '\n' # By convention, the second line (called H1 in the MATLAB documentation) # is read by various MATLAB functions, so it should be in the format # MATLAB expects - FUNCTIONNAME Summary. We read in this line and # add the Summary to the docstring. If the line doesn't match the # format, just write it to the docstring as is. line = in_file.readline() try: docstring += leader + line.split(' ')[1] + '\n' except IndexError: docstring += line + '\n' # Skip the next line, which is a duplicate of the first. It is here # because MATLAB doesn't show the function definition in its help. in_file.readline() # For the rest of the lines in the file, get the line if it is # in the first unbroken comment section and add it to the docstring. for line in in_file.readlines(): try: if line.lstrip().startswith('%'): docstring += leader + line.lstrip()[2:-1] + '\n' else: break except IndexError: docstring += '\n' return docstring + '\n' def get_function_name(function_string): """ Return the Matlab function or classdef signature, assuming that the string starts with either 'function ' or 'classdef '. """ if function_string.startswith('function '): sig = function_string[len('function '):] elif function_string.startswith('classdef '): sig = function_string[len('classdef '):] else: logger.error(f"Unknown function declaration in MATLAB document: {function_string}") sys.exit(1) # Split the function signature on the equals sign, if it exists. # We don't care about what comes before the equals sign, since # if a function returns, the docs will tell us. If there is no # =, return the whole signature. if '=' in sig: idx = sig.index('=') return sig[idx+2:] else: return sig if localenv['doxygen_docs'] or localenv['sphinx_docs']: docs = build(localenv.Command( '#build/doc/html/cxx/index.html', 'doxygen/Doxyfile', [ Delete("build/doc/html/cxx"), 'doxygen $SOURCE', Copy("build/doc/html/cxx", "build/doc/doxygen/html") ] )) env.Depends(docs, env.Glob('#doc/doxygen/*') + multi_glob(env, '#include/cantera', 'h') + multi_glob(env, '#include/cantera/*', 'h') + multi_glob(env, '#src/cantera/*', 'h', 'cpp')) env.Alias('doxygen', docs) if localenv['sphinx_docs']: localenv.PrependENVPath('PYTHONPATH', Dir('#build/python').abspath) def build_sphinx(target, source, env): cmd = [env['sphinx_cmd']] + env['sphinx_options'].split() if env['logging'] == 'debug': cmd.append('-v') cmd += ('-b', 'html', '-d', 'build/doc/sphinx/doctrees', 'build/doc/sphinx', 'build/doc/html') code = subprocess.call(cmd, env=env['ENV']) if code: raise Errors.BuildError(target, action=env['sphinx_cmd']) # RecursiveInstall knows to copy modified files, unlike Copy copy_sphinx = localenv.RecursiveInstall("#build/doc/sphinx", "sphinx") copy_python_samples = localenv.RecursiveInstall("#build/doc/samples/python", "#samples/python") copy_matlab_ex_samples = localenv.RecursiveInstall( "#build/doc/samples/matlab_experimental", "#samples/matlab_experimental") sphinxdocs = build(localenv.Command('build/doc/sphinx/html/index.html', 'sphinx/conf.py', build_sphinx)) env.Alias('sphinx', sphinxdocs) env.Depends(sphinxdocs, copy_sphinx) env.Depends(sphinxdocs, [copy_python_samples, copy_matlab_ex_samples]) env.Depends(sphinxdocs, env['python_module']) # Gather all C++ samples into a single directory so they can be presented a single # sub-gallery by sphinx-gallery cxxfiles = (Glob("#samples/cxx/*/*.cpp") + Glob("#samples/cxx/*/*.h") + Glob("#samples/cxx/*.rst")) for cxxfile in cxxfiles: env.Depends(sphinxdocs, localenv.Command(f"#build/doc/samples/cxx/{cxxfile.name}", cxxfile, Copy("$TARGET", "$SOURCE"))) # Gather all Fortran (F77 and F90) samples into a single directory fort_files = (Glob("#samples/f77/*.f") + Glob("#samples/f77/*.cpp") + Glob("#samples/f90/*.f90") + Glob("#samples/f90/README.rst")) for fort_file in fort_files: env.Depends(sphinxdocs, localenv.Command(f"#build/doc/samples/fortran/{fort_file.name}", fort_file, Copy("$TARGET", "$SOURCE"))) # Gather clib sample files clib_files = Glob("#samples/clib/*.c") + [File("#samples/clib/README.rst")] for clib_file in clib_files: env.Depends(sphinxdocs, localenv.Command(f"#build/doc/samples/clib/{clib_file.name}", clib_file, Copy("$TARGET", "$SOURCE"))) # Generate documentation for SCons configuration options def save_config(target, source, env): Path(str(target[0])).parent.mkdir(parents=True, exist_ok=True) with open(str(target[0]), "w") as outfile: outfile.write(env["config"].to_rest()) scons_opts = localenv.Command( "#build/doc/sphinx/develop/compiling/scons-config-options.rst.inc", "#SConstruct", save_config) env.Depends(sphinxdocs, scons_opts) # Create a list of MATLAB classes to document. This uses the NamedTuple # structure defined at the top of the file. The @Data and @Utilities # classes are fake classes for the purposes of documentation only. Each # Page represents one html page of the documentation. pages = [ Page('importing', 'Objects Representing Phases', ['@Solution', '@Mixture', '@Interface', '@Pure Fluid Phases']), Page('thermodynamics', 'Thermodynamic Properties', ['@ThermoPhase']), Page('kinetics', 'Chemical Kinetics', ['@Kinetics']), Page('transport', 'Transport Properties', ['@Transport']), Page('zero-dim', 'Zero-Dimensional Reactor Networks', ['@Func', '@Reactor', '@ReactorNet', '@FlowDevice', '@Wall']), Page('one-dim', 'One-Dimensional Reacting Flows', ['1D/@Domain1D', '1D/@Stack']), Page('data', 'Physical Constants', ['@Data']), Page('utilities', 'Utility Functions', ['@Utilities']), ] # Create a dictionary of extra files associated with each class. These # files are listed relative to the top directory interfaces/matlab/cantera extra = { '@Solution': ['GRI30.m', 'Air.m'], '@Pure Fluid Phases': ['CarbonDioxide.m', 'HFC134a.m', 'Hydrogen.m', 'Methane.m', 'Nitrogen.m', 'Oxygen.m', 'Water.m'], '@Func': ['gaussian.m', 'polynom.m'], '@Reactor': ['ConstPressureReactor.m', 'FlowReactor.m', 'IdealGasConstPressureReactor.m', 'IdealGasReactor.m', 'Reservoir.m'], '@FlowDevice': ['MassFlowController.m', 'Valve.m'], '1D/@Domain1D': ['1D/AxiStagnFlow.m', '1D/AxisymmetricFlow.m', '1D/Inlet.m', '1D/Outlet.m', '1D/OutletRes.m', '1D/Surface.m', '1D/SymmPlane.m'], '1D/@Stack': ['1D/FreeFlame.m', '1D/CounterFlowDiffusionFlame.m'], '@Interface': ['importEdge.m', 'importInterface.m'], '@Data': ['gasconstant.m', 'oneatm.m'], '@Utilities': ['adddir.m', 'cleanup.m', 'geterr.m', 'getDataDirectories.m', 'canteraVersion.m', 'canteraGitCommit.m'] } # These files do not need to be documented in the MATLAB classes because they # are generics that are overloaded per-class. Since the loop checks for these # strings in each file name, hndl.m is the same as *hndl.m* (to use globbing # notation). nodoc_matlab_files = ['clear.m', 'display.m', 'hndl.m', 'private', 'subsref.m'] # Loop through the pages list to document each class for page in pages: tempenv = env.Clone() # Set the title header title = page.title tempenv['title'] = '='*len(title) + '\n' + title + '\n' + '='*len(title) doc = '' # The base directory of the MATLAB toolbox relative to the sphinx build directory base = '../interfaces/matlab/toolbox' for obj in page.objects: all_files = [] # Set the subheader based on the class name doc += obj.split('@')[1] + '\n' + '-'*len(obj.split('@')[1]) + '\n\n' if os.path.isdir(pjoin(base,obj)): # Get a list of the functions in this class as long as its a file we care about functions = [name for name in os.listdir(pjoin(base,obj)) if not any(x in name for x in nodoc_matlab_files)] # Add the docstring for the class name at level 0 class_file = functions.pop(functions.index(obj.split('@')[1]+'.m')) doc += extract_matlab_docstring(os.path.relpath(pjoin(base,obj,class_file)), 0) # Get the extra files from the extra dictionary and sort them with # the regular functions. extra_files = extra.get(obj,[]) all_files += sorted(functions + extra_files) else: all_files = extra.get(obj,[]) for file in all_files: if file in functions: doc += extract_matlab_docstring(os.path.relpath(pjoin(base,obj,file)), 1) else: doc += extract_matlab_docstring(os.path.relpath(pjoin(base,file)), 1) tempenv['matlab_docstrings'] = doc # Substitute the docstrings into the proper file. Since the docs change # every time the source is changed, we don't want to have to commit the # change in the rst file as well as the source - too much code churn. So # we use a template and a SubstFile directive. c = tempenv.SubstFile('#build/doc/sphinx/matlab/%s.rst' % page.name, '#doc/sphinx/matlab/matlab-template.rst.in') build(c) localenv.Depends(sphinxdocs, c) localenv.AlwaysBuild(sphinxdocs) if localenv['doxygen_docs']: localenv.Depends(sphinxdocs, docs) install(localenv.RecursiveInstall, '$inst_docdir/html', '#build/doc/html', exclude=['\\.map', '\\.md5'])