diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 073515ad72..a31c3f2380 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -158,6 +158,20 @@ jobs: sphinxcontrib-katex sphinxcontrib-matlabdomain sphinxcontrib-doxylink - name: Build Cantera with documentation run: python3 `which scons` build -j2 doxygen_docs=y sphinx_docs=y debug=n optimize=n use_pch=n + - name: Capture configuration options from SConstruct + run: | + python3 site_scons/scons2rst.py --dev SConstruct config-options + mkdir build/docs/scons + mv config-options-dev.rst build/docs/scons/ + - name: Create archive for docs output + run: | + cd build + tar -czf docs.tar.gz docs + - name: Store archive of docs output + uses: actions/upload-artifact@v2 + with: + path: build/docs.tar.gz + retention-days: 2 # The known_hosts key is generated with `ssh-keygen -F cantera.org` from a # machine that has previously logged in to cantera.org and trusts # that it logged in to the right machine diff --git a/SConstruct b/SConstruct index 6907cadd46..44e93ccbf0 100644 --- a/SConstruct +++ b/SConstruct @@ -64,6 +64,11 @@ if not COMMAND_LINE_TARGETS: logger.info(__doc__, print_level=False) sys.exit(0) +if parse_version(SCons.__version__) < parse_version("3.0.0"): + logger.info("Cantera requires SCons with a minimum version of 3.0.0. Exiting.", + print_level=False) + sys.exit(0) + valid_commands = ("build", "clean", "install", "uninstall", "help", "msi", "samples", "sphinx", "doxygen", "dump") @@ -141,31 +146,70 @@ opts = Variables('cantera.conf') windows_compiler_options = [] extraEnvArgs = {} + +class Defaults: + """ + Class enabling selection of options based on attribute dictionary entries that + allof for differentiation between platform/compiler dependent options. + + Individual member variables are assigned explicitly after object instantiation, and + should contain strings specifying platform/compiler independent default options, or + dictionaries with platform/compiler dependent options. + """ + + def select(self, key="default"): + """Select attribute dictionary entries corresponding to *key*""" + for attr, val in self.__dict__.items(): + + if isinstance(val, dict) and key in val: + self.__dict__[attr] = val[key] + + +def substitute(entries, env=None): + """Function substituting environment variables in configuration""" + out = [] + for item in entries: + if env is not None and isinstance(item, tuple) and \ + not isinstance(item[2], bool) and "$" in item[2]: + subst = list(item[:2]) + [env.subst(item[2])] + list(item[3:]) + out.append(tuple(subst)) + else: + out.append(item) + + return out + + +defaults = Defaults() + +defaults.toolchain = {"Windows": "msvc"} +defaults.target_arch = {"Windows": "amd64"} + if os.name == 'nt': + defaults.select("Windows") + # On Windows, target the same architecture as the current copy of Python, # unless the user specified another option. - if '64 bit' in sys.version: - target_arch = 'amd64' - else: - target_arch = 'x86' + if "64 bit" not in sys.version: + defaults.target_arch = "x86" # Make an educated guess about the right default compiler if which('g++') and not which('cl.exe'): - defaultToolchain = 'mingw' - else: - defaultToolchain = 'msvc' + defaults.toolchain = "mingw" windows_compiler_options.extend([ - ('msvc_version', - """Version of Visual Studio to use. The default is the newest - installed version. Specify '12.0' for Visual Studio 2013 or '14.0' - for Visual Studio 2015.""", - ''), + ( + "msvc_version", + """Version of Visual Studio to use. The default is the newest + installed version. Specify '12.0' for Visual Studio 2013, '14.0' for + Visual Studio 2015, '14.1' ('14.1x') Visual Studio 2017, or '14.2' + ('14.2x') for Visual Studio 2019. For version numbers in parentheses, + 'x' is a placeholder for a minor version number. Windows MSVC only.""", + ""), EnumVariable( - 'target_arch', + "target_arch", """Target architecture. The default is the same architecture as the - installed version of Python.""", - target_arch, ('amd64', 'x86')) + installed version of Python. Windows only.""", + defaults.target_arch, ("amd64", "x86")) ]) opts.AddVariables(*windows_compiler_options) @@ -173,12 +217,14 @@ if os.name == 'nt': opts.Update(pickCompilerEnv) if pickCompilerEnv['msvc_version']: - defaultToolchain = 'msvc' + defaults.toolchain = "msvc" - windows_compiler_options.append(EnumVariable( - 'toolchain', - """The preferred compiler toolchain.""", - defaultToolchain, ('msvc', 'mingw', 'intel'))) + windows_compiler_options.append( + EnumVariable( + "toolchain", + """The preferred compiler toolchain. If MSVC is not on the path but + 'g++' is on the path, 'mingw' is used as a backup. Windows only.""", + defaults.toolchain, ("msvc", "mingw", "intel"))) opts.AddVariables(windows_compiler_options[-1]) opts.Update(pickCompilerEnv) @@ -237,112 +283,112 @@ if 'FRAMEWORKS' not in env: add_RegressionTest(env) -class defaults: pass +defaults.prefix = {"Windows": "$ProgramFiles\Cantera", "default": "/usr/local"} +defaults.boost_inc_dir = "" if os.name == 'posix': - defaults.prefix = '/usr/local' - defaults.boostIncDir = '' env['INSTALL_MANPAGES'] = True elif os.name == 'nt': defaults.prefix = pjoin(os.environ['ProgramFiles'], 'Cantera') - defaults.boostIncDir = '' env['INSTALL_MANPAGES'] = False else: print("Error: Unrecognized operating system '%s'" % os.name) sys.exit(1) +defaults.cxx = "${CXX}" +defaults.cc = "${CC}" + compiler_options = [ ('CXX', """The C++ compiler to use.""", - env['CXX']), + defaults.cxx), ('CC', """The C compiler to use. This is only used to compile CVODE.""", - env['CC'])] + defaults.cc)] + +compiler_options = substitute(compiler_options, env=env) opts.AddVariables(*compiler_options) opts.Update(env) -defaults.cxxFlags = '' -defaults.ccFlags = '' -defaults.noOptimizeCcFlags = '-O0' -defaults.optimizeCcFlags = '-O3' -defaults.debugCcFlags = '-g' -defaults.noDebugCcFlags = '' -defaults.debugLinkFlags = '' -defaults.noDebugLinkFlags = '' -defaults.warningFlags = '-Wall' -defaults.buildPch = False -defaults.sphinx_options = '-W --keep-going' -env['pch_flags'] = [] -env['openmp_flag'] = ['-fopenmp'] # used to generate sample build scripts +defaults.cxx_flags = { + "cl": "/EHsc", + "Cygwin": "-std=gnu++11", # See http://stackoverflow.com/questions/18784112 + "default": "-std=c++11" +} +defaults.cc_flags = { + "cl": "/MD /nologo /D_SCL_SECURE_NO_WARNINGS /D_CRT_SECURE_NO_WARNINGS", + "icc": "-vec-report0 -diag-disable 1478", + "clang": "-fcolor-diagnostics", + "default": "", +} +defaults.no_optimize_cc_flags = {"cl": "/Od /Ob0", "default": "-O0"} +defaults.optimize_cc_flags = { + "cl": "/O2", + "gcc": "-O3 -Wno-inline", + "default": "-O3", +} +defaults.debug_cc_flags = {"cl": "/Zi /Fd${TARGET}.pdb", "default": "-g"} +defaults.no_debug_cc_flags = "" +defaults.debug_link_flags = {"cl": "/DEBUG", "default": ""} +defaults.no_debug_link_flags = "" +defaults.warning_flags = {"cl": "/W3", "icc": "-Wcheck", "default": "-Wall"} +defaults.build_pch = {"icc": False, "default": True} +defaults.pch_flags = { + "cl": "/FIpch/system.h", + "gcc": "-include src/pch/system.h", + "clang": "-include-pch src/pch/system.h.gch", + "default": "", +} +defaults.thread_flags = {"Windows": "", "macOS": "", "default": "-pthread"} +defaults.openmp_flag = { + "cl": "/openmp", + "icc": "openmp", + "apple-clang": "-Xpreprocessor -fopenmp", + "default": "-fopenmp", +} +defaults.fs_layout = {"Windows": "compact", "default": "standard"} +defaults.python_prefix = {"Windows": "", "default": "$prefix"} +defaults.python_cmd = "${PYTHON_CMD}" +defaults.env_vars = "PATH,LD_LIBRARY_PATH,PYTHONPATH" +defaults.versioned_shared_library = {"mingw": False, "default": True} +defaults.sphinx_options = "-W --keep-going" -env['using_apple_clang'] = False # Check if this is actually Apple's clang on macOS +env['using_apple_clang'] = False if env['OS'] == 'Darwin': result = subprocess.check_output([env.subst('$CC'), '--version']).decode('utf-8') if 'clang' in result.lower() and ('Xcode' in result or 'Apple' in result): env['using_apple_clang'] = True - env['openmp_flag'].insert(0, '-Xpreprocessor') + defaults.select("apple-clang") if 'gcc' in env.subst('$CC') or 'gnu-cc' in env.subst('$CC'): - defaults.optimizeCcFlags += ' -Wno-inline' if env['OS'] == 'Cygwin': - # See http://stackoverflow.com/questions/18784112 - defaults.cxxFlags = '-std=gnu++11' - else: - defaults.cxxFlags = '-std=c++11' - defaults.buildPch = True - env['pch_flags'] = ['-include', 'src/pch/system.h'] + defaults.select("Cygwin") + defaults.select("gcc") elif env['CC'] == 'cl': # Visual Studio - defaults.cxxFlags = ['/EHsc'] - defaults.ccFlags = ['/MD', '/nologo', - '/D_SCL_SECURE_NO_WARNINGS', '/D_CRT_SECURE_NO_WARNINGS'] - defaults.debugCcFlags = '/Zi /Fd${TARGET}.pdb' - defaults.noOptimizeCcFlags = '/Od /Ob0' - defaults.optimizeCcFlags = '/O2' - defaults.debugLinkFlags = '/DEBUG' - defaults.warningFlags = '/W3' - defaults.buildPch = True - env['pch_flags'] = ['/FIpch/system.h'] - env['openmp_flag'] = ['/openmp'] + defaults.select("cl") elif 'icc' in env.subst('$CC'): - defaults.cxxFlags = '-std=c++11' - defaults.ccFlags = '-vec-report0 -diag-disable 1478' - defaults.warningFlags = '-Wcheck' - env['openmp_flag'] = ['-openmp'] + defaults.select("icc") elif 'clang' in env.subst('$CC'): - defaults.ccFlags = '-fcolor-diagnostics' - defaults.cxxFlags = '-std=c++11' - defaults.buildPch = True - env['pch_flags'] = ['-include-pch', 'src/pch/system.h.gch'] + defaults.select("clang") else: print("WARNING: Unrecognized C compiler '%s'" % env['CC']) -if env['OS'] in ('Windows', 'Darwin'): - defaults.threadFlags = '' -else: - defaults.threadFlags = '-pthread' +if env['OS'] == 'Windows': + defaults.select("Windows") +elif env["OS"] == "Darwin": + defaults.select("macOS") -# InstallVersionedLib only fully functional in SCons >= 2.4.0 # SHLIBVERSION fails with MinGW: http://scons.tigris.org/issues/show_bug.cgi?id=3035 -if (env['toolchain'] == 'mingw' - or parse_version(SCons.__version__) < parse_version('2.4.0')): - defaults.versionedSharedLibrary = False -else: - defaults.versionedSharedLibrary = True - -defaults.fsLayout = 'compact' if env['OS'] == 'Windows' else 'standard' -defaults.env_vars = 'PATH,LD_LIBRARY_PATH,PYTHONPATH' - -defaults.python_prefix = '$prefix' if env['OS'] != 'Windows' else '' +if (env["toolchain"] == "mingw"): + defaults.select("mingw") -# Transform lists into strings to keep cantera.conf clean -for key,value in defaults.__dict__.items(): - if isinstance(value, (list, tuple)): - setattr(defaults, key, ' '.join(value)) +defaults.select("default") +defaults.python_cmd = sys.executable # ************************************** # *** Read user-configurable options *** @@ -350,36 +396,38 @@ for key,value in defaults.__dict__.items(): config_options = [ PathVariable( - 'prefix', - 'Set this to the directory where Cantera should be installed.', + "prefix", + """Set this to the directory where Cantera should be installed. On Windows + systems, '$ProgramFiles' typically refers to "C:\Program Files".""", defaults.prefix, PathVariable.PathAccept), PathVariable( 'libdirname', """Set this to the directory where Cantera libraries should be installed. - Some distributions (for example, Fedora/RHEL) use 'lib64' instead of 'lib' on 64-bit systems - or could use some other library directory name instead of 'lib' depends - on architecture and profile (for example, Gentoo 'libx32' on x32 profile). - If user didn't set 'libdirname' configuration variable set it to default value 'lib'""", + Some distributions (for example, Fedora/RHEL) use 'lib64' instead of 'lib' + on 64-bit systems or could use some other library directory name instead of + 'lib' depends on architecture and profile (for example, Gentoo 'libx32' on + x32 profile). If the user didn't set the 'libdirname' configuration + variable, set it to the default value 'lib'""", 'lib', PathVariable.PathAccept), EnumVariable( 'python_package', - """If you plan to work in Python, then you need the ``full`` Cantera Python + """If you plan to work in Python, then you need the 'full' Cantera Python package. If, on the other hand, you will only use Cantera from some other language (for example, MATLAB or Fortran 90/95) and only need Python - to process CTI files, then you only need a ``minimal`` subset of the - package and Cython and NumPy are not necessary. The ``none`` option + to process YAML files, then you only need a 'minimal' subset of the + package and Cython and NumPy are not necessary. The 'none' option doesn't install any components of the Python interface. The default behavior is to build the full Python module for whichever version of Python is running SCons if the required prerequisites (NumPy and - Cython) are installed. Note: ``y`` is a synonym for ``full`` and ``n`` - is a synonym for ``none``.""", + Cython) are installed. Note: 'y' is a synonym for 'full' and 'n' + is a synonym for 'none'.""", 'default', ('full', 'minimal', 'none', 'n', 'y', 'default')), PathVariable( 'python_cmd', """Cantera needs to know where to find the Python interpreter. If - PYTHON_CMD is not set, then the configuration process will use the + 'PYTHON_CMD' is not set, then the configuration process will use the same Python interpreter being used by SCons.""", - sys.executable, PathVariable.PathAccept), + defaults.python_cmd, PathVariable.PathAccept), PathVariable( 'python_prefix', """Use this option if you want to install the Cantera Python package to @@ -401,8 +449,8 @@ config_options = [ 'matlab_path', """Path to the MATLAB install directory. This should be the directory containing the 'extern', 'bin', etc. subdirectories. Typical values - are: "C:/Program Files/MATLAB/R2011a" on Windows, - "/Applications/MATLAB_R2011a.app" on OS X, or + are: "C:\Program Files\MATLAB\R2021a" on Windows, + "/Applications/MATLAB_R2011a.app" on macOS, or "/opt/MATLAB/R2011a" on Linux.""", '', PathVariable.PathAccept), EnumVariable( @@ -418,9 +466,10 @@ config_options = [ a compatible compiler (pgfortran, gfortran, ifort, g95) in the 'PATH' environment variable. Used only for compiling the Fortran 90 interface.""", '', PathVariable.PathAccept), - ('FORTRANFLAGS', - 'Compilation options for the Fortran (90) compiler.', - '-O3'), + ( + "FORTRANFLAGS", + """Compilation options for the Fortran (90) compiler.""", + "-O3"), BoolVariable( 'coverage', """Enable collection of code coverage information with gcov. @@ -457,7 +506,7 @@ config_options = [ 'system_fmt', """Select whether to use the fmt library from a system installation ('y'), from a Git submodule ('n'), or to decide automatically - ('default'). If you do not want to use the Git submodule and fmt + ('default'). If you do not want to use the Git submodule and fmt is not installed directly into system include and library directories, then you will need to add those directories to 'extra_inc_dirs' and 'extra_lib_dirs'. This installation of fmt @@ -506,13 +555,13 @@ config_options = [ 'blas_lapack_dir', """Directory containing the libraries specified by 'blas_lapack_libs'. Not needed if the libraries are installed in a standard location, for example, - ``/usr/lib``.""", + '/usr/lib'.""", '', PathVariable.PathAccept), EnumVariable( 'lapack_names', """Set depending on whether the procedure names in the specified libraries are lowercase or uppercase. If you don't know, run 'nm' on - the library file (for example, 'nm libblas.a').""", + the library file (for example, "nm libblas.a").""", 'lower', ('lower','upper')), BoolVariable( 'lapack_ftn_trailing_underscore', @@ -535,27 +584,31 @@ config_options = [ ( 'env_vars', """Environment variables to propagate through to SCons. Either the - string "all" or a comma separated list of variable names, for example, + string 'all' or a comma separated list of variable names, for example, 'LD_LIBRARY_PATH,HOME'.""", defaults.env_vars), BoolVariable( 'use_pch', """Use a precompiled-header to speed up compilation""", - defaults.buildPch), + defaults.build_pch), + ( + 'pch_flags', + """Compiler flags when using precompiled-header.""", + defaults.pch_flags), ( 'cxx_flags', """Compiler flags passed to the C++ compiler only. Separate multiple options with spaces, for example, "cxx_flags='-g -Wextra -O3 --std=c++11'" """, - defaults.cxxFlags), + defaults.cxx_flags), ( 'cc_flags', """Compiler flags passed to both the C and C++ compilers, regardless of optimization level.""", - defaults.ccFlags), + defaults.cc_flags), ( 'thread_flags', """Compiler and linker flags for POSIX multithreading support.""", - defaults.threadFlags), + defaults.thread_flags), BoolVariable( 'optimize', """Enable extra compiler optimizations specified by the @@ -565,11 +618,11 @@ config_options = [ ( 'optimize_flags', """Additional compiler flags passed to the C/C++ compiler when 'optimize=yes'.""", - defaults.optimizeCcFlags), + defaults.optimize_cc_flags), ( 'no_optimize_flags', """Additional compiler flags passed to the C/C++ compiler when 'optimize=no'.""", - defaults.noOptimizeCcFlags), + defaults.no_optimize_cc_flags), BoolVariable( 'debug', """Enable compiler debugging symbols.""", @@ -577,25 +630,25 @@ config_options = [ ( 'debug_flags', """Additional compiler flags passed to the C/C++ compiler when 'debug=yes'.""", - defaults.debugCcFlags), + defaults.debug_cc_flags), ( 'no_debug_flags', """Additional compiler flags passed to the C/C++ compiler when 'debug=no'.""", - defaults.noDebugCcFlags), + defaults.no_debug_cc_flags), ( 'debug_linker_flags', """Additional options passed to the linker when 'debug=yes'.""", - defaults.debugLinkFlags), + defaults.debug_link_flags), ( 'no_debug_linker_flags', """Additional options passed to the linker when 'debug=no'.""", - defaults.noDebugLinkFlags), + defaults.no_debug_link_flags), ( 'warning_flags', """Additional compiler flags passed to the C/C++ compiler to enable extra warnings. Used only when compiling source code that is part of Cantera (for example, excluding code in the 'ext' directory).""", - defaults.warningFlags), + defaults.warning_flags), ( 'extra_inc_dirs', """Additional directories to search for header files, with multiple @@ -610,7 +663,7 @@ config_options = [ 'boost_inc_dir', """Location of the Boost header files. Not needed if the headers are installed in a standard location, for example, '/usr/include'.""", - defaults.boostIncDir, PathVariable.PathAccept), + defaults.boost_inc_dir, PathVariable.PathAccept), PathVariable( 'stage_dir', """Directory relative to the Cantera source directory to be @@ -644,22 +697,27 @@ config_options = [ actual library and 'libcantera_shared.so' and 'libcantera_shared.so.2' as symlinks. """, - defaults.versionedSharedLibrary), + defaults.versioned_shared_library), BoolVariable( 'use_rpath_linkage', """If enabled, link to all shared libraries using 'rpath', i.e., a fixed run-time search path for dynamic library loading.""", True), + ( + "openmp_flag", + """Compiler flags used for multiprocessing (only used to generate sample build + scripts).""", + defaults.openmp_flag), EnumVariable( 'layout', """The layout of the directory structure. 'standard' installs files to several subdirectories under 'prefix', for example, 'prefix/bin', 'prefix/include/cantera', 'prefix/lib' etc. This layout is best used in - conjunction with "prefix'='/usr/local'". 'compact' puts all installed + conjunction with "prefix='/usr/local'". 'compact' puts all installed files in the subdirectory defined by 'prefix'. This layout is best with a prefix like '/opt/cantera'. 'debian' installs to the stage directory in a layout used for generating Debian packages.""", - defaults.fsLayout, ('standard','compact','debian')), + defaults.fs_layout, ('standard','compact','debian')), BoolVariable( "fast_fail_tests", """If enabled, tests will exit at the first failure.""", @@ -690,6 +748,8 @@ config_options = [ True), ] +config_options = substitute(config_options, env=env) + opts.AddVariables(*config_options) opts.Update(env) opts.Save('cantera.conf', env) @@ -843,6 +903,8 @@ env['LINKFLAGS'] += listify(env['thread_flags']) env['CPPDEFINES'] = {} env['warning_flags'] = listify(env['warning_flags']) +env["pch_flags"] = listify(env["pch_flags"]) +env["openmp_flag"] = listify(env["openmp_flag"]) if env['optimize']: env['CCFLAGS'] += listify(env['optimize_flags']) diff --git a/site_scons/scons2rst.py b/site_scons/scons2rst.py new file mode 100644 index 0000000000..fed1fee0e5 --- /dev/null +++ b/site_scons/scons2rst.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +""" +scons2rst.py: Extract SCons configuration options from SConstruct +""" + +import ast +import re +import argparse +import pathlib + + +def deblank(string): + """ + Remove whitespace before and after line breaks + """ + out = [s.strip() for s in string.split("\n")] + if not len(out[-1]): + out = out[:-1] + out = "\n".join(out) + # out = out.replace("\", "\").replace("$", "\$") + return out + + +def convert(item): + """ + Convert AST objects to human-readable strings (or list of strings) + """ + if isinstance(item, list): + return [convert(sub) for sub in item] + + if isinstance(item, (ast.Tuple, ast.List)): + return [convert(sub) for sub in item.elts] + + if isinstance(item, ast.Str): + return deblank(item.s) + + if isinstance(item, (ast.NameConstant)): + if isinstance(item.value, bool): + return "True" if item.value else "False" + raise ValueError(f"Unable to process constant '{item}'") + + if isinstance(item, ast.Attribute): + if isinstance(item.value, ast.Name): + return f"{item.value.id}.{item.attr}" + raise ValueError(f"Unable to process attribute '{item}'") + + if isinstance(item, ast.Subscript): + if isinstance(item.value, ast.Name) and isinstance(item.slice.value, ast.Str): + return f"{item.value.id}[{item.slice.value.s}]" + raise ValueError(f"Unable to process subscript '{item}'") + + if isinstance(item, ast.Name): + return item.id + + raise ValueError(f"Unable to process item of type '{type(item)}'") + + +class Option: + """ + Object corresponding to SCons configuration option + """ + + def __init__(self, item): + if isinstance(item, (ast.Tuple, ast.List)): + self.func = None + self._assign(convert(item)) + return + + if isinstance(item, ast.Call): + self.func = item.func.id + self._assign(convert(item.args)) + return + + raise ValueError(f"Unable to process item of type '{type(item)}'") + + def _assign(self, args): + self.name = args[0] + self.description = args[1] + self.default = args[2] + self.choices = args[3] if len(args) > 3 else None + + @property + def args(self): + """ + Arguments of the SCons configuration option + """ + if self.choices is None: + return self.name, self.description, self.default + return self.name, self.description, self.default, self.choices + + def to_rest(self, defaults=None, dev=False, indent=3): + """ + Convert option to restructured text + """ + tag = self.name.replace("_", "-").lower() + if dev: + tag += "-dev" + + if self.func == "PathVariable": + choices = f"``path/to/{self.name}``" + default = self.default + elif isinstance(self.choices, list): + choices = self.choices + for yes_no in ["n", "y"]: + if yes_no in choices: + # ensure correct order + choices.remove(yes_no) + choices = [yes_no] + choices + choices = " | ".join([f"``'{c}'``" for c in choices]) + default = self.default + elif self.default in ["True", "False"]: + choices = "``'yes'`` | ``'no'``" + default = "yes" if self.default == "True" else "no" + elif self.func == "BoolVariable": + choices = "``'yes'`` | ``'no'``" + default = self.default + else: + choices = "``string``" + default = self.default + + if default.startswith("defaults."): + if defaults is None: + default = "" + else: + attr = default.split('.')[1] + if attr in defaults: + default = defaults[attr] + else: + default = "" + elif default.startswith("sys."): + default = "" + else: + default = f"{default}" + + # formatting shortcuts + tab = " " * indent + bullet = "*" + bullet = f"{bullet:<{indent}}" + dash = "-" + dash = f"{dash:<{indent}}" + + # assemble description + linebreak = "\n" + tab + description = linebreak.join(self.description.split("\n")) + pat = r'"([a-zA-Z0-9\-\+\*$_.,: =/\'\\]+)"' + double_quoted = [] + for item in re.findall(pat, description): + # enclose double-quoted strings in '``' + found = f'"{item}"' + double_quoted += [found] + replacement = f"``{found}``" + description = description.replace(found, replacement) + pat = r"\'([a-zA-Z0-9\-\+\*$_.,:=/\\]+)\'" + for item in re.findall(pat, description): + # replace "'" for single-quoted words by '``'; do not replace "'" when + # whitespace is enclosed or if word is part of double-quoted string + if any([item in dq for dq in double_quoted]): + continue + found = f"'{item}'" + replacement = found.replace("'", "``") + description = description.replace(found, replacement) + pat = r"\*([^\*]+)" + asterisks = re.findall(pat, description) + if len(asterisks) == 1: + # catch unbalanced '*', for example in '*nix' + found = f"*{asterisks[0]}" + replacement = f"\{found}" + description = description.replace(found, replacement) + + # assemble output + out = f".. _{tag}:\n\n" + out += f"{bullet}``{self.name}``: [ {choices} ]\n" + out += f"{tab}{description}\n\n" + + # add information on default values + if isinstance(default, dict): + comment, defaults = Option.parse_defaults(default) + out += f"{tab}{dash}default: {comment}\n\n" + for key, value in defaults.items(): + if key == "default": + out += f"{tab}{tab}{dash}Otherwise: ``'{value}'``\n" + else: + out += f"{tab}{tab}{dash}{key}: ``'{value}'``\n" + else: + out += f"{tab}{dash}default: ``'{default}'``\n" + + return out + + @staticmethod + def parse_defaults(defaults): + # check if this is a compiler option + compilers = {"cl": "MSVC", "gcc": "GCC", "icc": "ICC", "clang": "Clang"} + if not any([d in compilers for d in defaults]): + return "platform dependent", defaults + + out = {} + for key, value in defaults.items(): + if key in compilers: + key = f"If using {compilers[key]}" + elif key != "default": + key = f"If using {key}" + if isinstance(value, bool): + value = "yes" if value else "no" + out[key] = value + return "compiler dependent", out + + def __repr__(self): + if self.func is None: + return f"{self.args}" + return f"{self.func}{self.args}" + + +def parse(input_file, output_file, dev, no_defaults): + """Parse SConstruct and extract configuration""" + + with open(input_file, "r") as fid: + code = fid.read() + + tree = ast.parse(code) + + names = ["windows_compiler_options", "compiler_options", "config_options"] + options = {name: [] for name in names} + defaults = {} + + for node in ast.walk(tree): + + if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name) \ + and len(node.targets) == 1: + + # configuration option is assigned as list + name = node.targets[0].id + if name not in names: + continue + + if isinstance(node.value, ast.List): + for item in node.value.elts: + options[name].append(Option(item)) + continue + + if isinstance(node.value, ast.Call): + continue + + raise ValueError(f"Unable to process node {node.value} (name='{name}')") + + elif isinstance(node, ast.Assign) \ + and isinstance(node.targets[0], ast.Attribute) \ + and len(node.targets) == 1: + + # retrieve 'defaults' used for platform-dependent configurations + name = node.targets[0].value.id + if name == "defaults": + attr = node.targets[0].attr + + if attr in defaults: + # do not overwrite values + continue + + if isinstance(node.value, ast.Str): + defaults[attr] = node.value.s + continue + + if isinstance(node.value, ast.Dict): + items = {} + for k, v in zip(node.value.keys, node.value.values): + items[k.value] = v.value + defaults[attr] = items + continue + + if isinstance(node.value, ast.Call): + continue + + raise ValueError(f"Unable to process node {node.value} (name='{name}')") + + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) \ + and isinstance(node.func.value, ast.Name): + + # configuration option is appended to list via 'append' or 'extend' + if node.func.attr not in ["append", "extend"]: + continue + + name = node.func.value.id + if name not in names: + continue + + for arg in node.args: + if isinstance(arg, ast.List): + for item in arg.elts: + options[name].append(Option(item)) + continue + + if isinstance(arg, ast.Call): + options[name].append(Option(arg)) + continue + + raise ValueError(f"Unable to process node {node}") + + if no_defaults: + defaults = None + + with open(output_file, "w+") as fid: + for config in names: + for option in options[config]: + fid.write(option.to_rest(defaults=defaults, dev=dev)) + fid.write("\n") + + print(f"Done writing output to '{output_file}'.") + + +def main(): + parser = argparse.ArgumentParser( + description="Extract configuration options from SConstruct", + ) + parser.add_argument( + "input", + nargs="?", + help="The input SConstruct file. Optional.", + default="SConstruct") + parser.add_argument( + "output", + nargs="?", + help="The output ReST filename. Optional.", + default="config-options") + parser.add_argument( + "--dev", + action="store_true", + default=False, + help="Append '-dev' to filename and Sphinx references.") + parser.add_argument( + "--no-defaults", + action="store_true", + default=False, + help="Do not apply default values.") + + args = parser.parse_args() + input_file = pathlib.Path(args.input) + output_file = args.output + if args.dev: + output_file += "-dev" + output_file = pathlib.Path(output_file).with_suffix(".rst") + + parse(input_file, output_file, args.dev, args.no_defaults) + + +if __name__ == "__main__": + main()