"""Makefile options for stanc and C++ compilers"""importioimportjsonimportosimportplatformimportshutilimportsubprocessfromcopyimportcopyfromdatetimeimportdatetimefrompathlibimportPathfromtypingimportAny,Dict,Iterable,List,Optional,Unionfromcmdstanpy.utilsimportget_loggerfromcmdstanpy.utils.cmdstanimport(EXTENSION,cmdstan_path,cmdstan_version,cmdstan_version_before,)fromcmdstanpy.utils.commandimportdo_commandfromcmdstanpy.utils.filesystemimportSanitizedOrTmpFilePathSTANC_OPTS=['O','O0','O1','Oexperimental','allow-undefined','use-opencl','warn-uninitialized','include-paths','name','warn-pedantic',]# TODO(2.0): removeSTANC_DEPRECATED_OPTS={'allow_undefined':'allow-undefined','include_paths':'include-paths',}STANC_IGNORE_OPTS=['debug-lex','debug-parse','debug-ast','debug-decorated-ast','debug-generate-data','debug-mir','debug-mir-pretty','debug-optimized-mir','debug-optimized-mir-pretty','debug-transformed-mir','debug-transformed-mir-pretty','dump-stan-math-signatures','auto-format','print-canonical','print-cpp','o','help','version',]OptionalPath=Union[str,os.PathLike,None]# TODO(2.0): can remove add function and other logic
[docs]classCompilerOptions:""" User-specified flags for stanc and C++ compiler. Attributes: stanc_options - stanc compiler flags, options cpp_options - makefile options (NAME=value) user_header - path to a user .hpp file to include during compilation """def__init__(self,*,stanc_options:Optional[Dict[str,Any]]=None,cpp_options:Optional[Dict[str,Any]]=None,user_header:OptionalPath=None,)->None:"""Initialize object."""self._stanc_options=stanc_optionsifstanc_optionsisnotNoneelse{}self._cpp_options=cpp_optionsifcpp_optionsisnotNoneelse{}self._user_header=str(user_header)ifuser_headerisnotNoneelse''def__repr__(self)->str:return'stanc_options={}, cpp_options={}'.format(self._stanc_options,self._cpp_options)def__eq__(self,other:Any)->bool:"""Overrides the default implementation"""ifself.is_empty()andotherisNone:# equiv w/r/t compilerreturnTrueifnotisinstance(other,CompilerOptions):returnFalsereturn(self._stanc_options==other.stanc_optionsandself._cpp_options==other.cpp_optionsandself._user_header==other.user_header)
[docs]defis_empty(self)->bool:"""True if no options specified."""return(self._stanc_options=={}andself._cpp_options=={}andself._user_header=='')
[docs]defvalidate(self)->None:""" Check compiler args. Raise ValueError if invalid options are found. """self.validate_stanc_opts()self.validate_cpp_opts()self.validate_user_header()
[docs]defvalidate_stanc_opts(self)->None:""" Check stanc compiler args and consistency between stanc and C++ options. Raise ValueError if bad config is found. """# pylint: disable=no-memberifself._stanc_optionsisNone:returnignore=[]paths=Nonehas_o_flag=Falsefordeprecated,replacementinSTANC_DEPRECATED_OPTS.items():ifdeprecatedinself._stanc_options:ifreplacement:get_logger().warning('compiler option "%s" is deprecated, use "%s" instead',deprecated,replacement,)self._stanc_options[replacement]=copy(self._stanc_options[deprecated])delself._stanc_options[deprecated]else:get_logger().warning('compiler option "%s" is deprecated and ''should not be used',deprecated,)forkey,valinself._stanc_options.items():ifkeyinSTANC_IGNORE_OPTS:get_logger().info('ignoring compiler option: %s',key)ignore.append(key)elifkeynotinSTANC_OPTS:raiseValueError(f'unknown stanc compiler option: {key}')elifkey=='include-paths':paths=valifisinstance(val,str):paths=val.split(',')elifnotisinstance(val,list):raiseValueError('Invalid include-paths, expecting list or 'f'string, found type: {type(val)}.')elifkey=='use-opencl':ifself._cpp_optionsisNone:self._cpp_options={'STAN_OPENCL':'TRUE'}else:self._cpp_options['STAN_OPENCL']='TRUE'elifkey.startswith('O'):ifhas_o_flag:get_logger().warning('More than one of (O, O1, O2, Oexperimental)''optimizations passed. Only the last one will''be used')else:has_o_flag=Trueforoptinignore:delself._stanc_options[opt]ifpathsisnotNone:bad_paths=[dirfordirinpathsifnotos.path.exists(dir)]ifany(bad_paths):raiseValueError('invalid include paths: {}'.format(', '.join(bad_paths)))self._stanc_options['include-paths']=[os.path.abspath(os.path.expanduser(path))forpathinpaths]
[docs]defvalidate_cpp_opts(self)->None:""" Check cpp compiler args. Raise ValueError if bad config is found. """ifself._cpp_optionsisNone:returnforkeyin['OPENCL_DEVICE_ID','OPENCL_PLATFORM_ID']:ifkeyinself._cpp_options:self._cpp_options['STAN_OPENCL']='TRUE'val=self._cpp_options[key]ifnotisinstance(val,int)orval<0:raiseValueError(f'{key} must be a non-negative integer value,'f' found {val}.')
[docs]defvalidate_user_header(self)->None:""" User header exists. Raise ValueError if bad config is found. """ifself._user_header!="":ifnot(os.path.exists(self._user_header)andos.path.isfile(self._user_header)):raiseValueError(f"User header file {self._user_header} cannot be found")ifself._user_header[-4:]!='.hpp':raiseValueError(f"Header file must end in .hpp, got {self._user_header}")if"allow-undefined"notinself._stanc_options:self._stanc_options["allow-undefined"]=True# set full pathself._user_header=os.path.abspath(self._user_header)if' 'inself._user_header:raiseValueError("User header must be in a location with no spaces in path!")if('USER_HEADER'inself._cpp_optionsandself._user_header!=self._cpp_options['USER_HEADER']):raiseValueError("Disagreement in user_header C++ options found!\n"f"{self._user_header}, {self._cpp_options['USER_HEADER']}")self._cpp_options['USER_HEADER']=self._user_header
[docs]defadd(self,new_opts:"CompilerOptions")->None:# noqa: disable=Q000"""Adds options to existing set of compiler options."""ifnew_opts.stanc_optionsisnotNone:ifself._stanc_optionsisNone:self._stanc_options=new_opts.stanc_optionselse:forkey,valinnew_opts.stanc_options.items():ifkey=='include-paths':ifisinstance(val,Iterable)andnotisinstance(val,str):forpathinval:self.add_include_path(str(path))else:self.add_include_path(str(val))else:self._stanc_options[key]=valifnew_opts.cpp_optionsisnotNone:forkey,valinnew_opts.cpp_options.items():self._cpp_options[key]=valifnew_opts._user_header!=''andself._user_header=='':self._user_header=new_opts._user_header
[docs]defadd_include_path(self,path:str)->None:"""Adds include path to existing set of compiler options."""path=os.path.abspath(os.path.expanduser(path))if'include-paths'notinself._stanc_options:self._stanc_options['include-paths']=[path]elifpathnotinself._stanc_options['include-paths']:self._stanc_options['include-paths'].append(path)
[docs]defcompose(self,filename_in_msg:Optional[str]=None)->List[str]:""" Format makefile options as list of strings. Parameters ---------- filename_in_msg : str, optional filename to be displayed in stanc3 error messages (if different from actual filename on disk), by default None """opts=['STANCFLAGS+='+flag.replace(" ","\\ ")forflaginself.compose_stanc(filename_in_msg)]ifself._cpp_optionsisnotNoneandlen(self._cpp_options)>0:forkey,valinself._cpp_options.items():opts.append(f'{key}={val}')returnopts
defsrc_info(stan_file:str,compiler_options:CompilerOptions)->Dict[str,Any]:""" Get source info for Stan program file. This function is used in the implementation of :meth:`CmdStanModel.src_info`, and should not be called directly. """cmd=([os.path.join(cmdstan_path(),'bin','stanc'+EXTENSION)]# handle include-paths, allow-undefined etc+compiler_options.compose_stanc(None)+['--info',str(stan_file)])proc=subprocess.run(cmd,capture_output=True,text=True,check=False)ifproc.returncode:raiseValueError(f"Failed to get source info for Stan model "f"'{stan_file}'. Console:\n{proc.stderr}")result:Dict[str,Any]=json.loads(proc.stdout)returnresult
[docs]defcompile_stan_file(src:Union[str,Path],force:bool=False,stanc_options:Optional[Dict[str,Any]]=None,cpp_options:Optional[Dict[str,Any]]=None,user_header:OptionalPath=None,)->str:""" Compile the given Stan program file. Translates the Stan code to C++, then calls the C++ compiler. By default, this function compares the timestamps on the source and executable files; if the executable is newer than the source file, it will not recompile the file, unless argument ``force`` is ``True`` or unless the compiler options have been changed. :param src: Path to Stan program file. :param force: When ``True``, always compile, even if the executable file is newer than the source file. Used for Stan models which have ``#include`` directives in order to force recompilation when changes are made to the included files. :param stanc_options: Options for stanc compiler. :param cpp_options: Options for C++ compiler. :param user_header: A path to a header file to include during C++ compilation. """src=Path(src).resolve()ifnotsrc.exists():raiseValueError(f'stan file does not exist: {src}')compiler_options=CompilerOptions(stanc_options=stanc_options,cpp_options=cpp_options,user_header=user_header,)compiler_options.validate()exe_target=src.with_suffix(EXTENSION)ifexe_target.exists():exe_time=os.path.getmtime(exe_target)included_files=[src]included_files.extend(src_info(str(src),compiler_options).get('included_files',[]))out_of_date=any(os.path.getmtime(included_file)>exe_timeforincluded_fileinincluded_files)ifnotout_of_dateandnotforce:get_logger().debug('found newer exe file, not recompiling')returnstr(exe_target)compilation_failed=False# if target path has spaces or special characters, use a copy in a# temporary directory (GNU-Make constraint)withSanitizedOrTmpFilePath(str(src))as(stan_file,is_copied):exe_file=os.path.splitext(stan_file)[0]+EXTENSIONhpp_file=os.path.splitext(exe_file)[0]+'.hpp'ifos.path.exists(hpp_file):os.remove(hpp_file)ifos.path.exists(exe_file):get_logger().debug('Removing %s',exe_file)os.remove(exe_file)get_logger().info('compiling stan file %s to exe file %s',stan_file,exe_target,)make=os.getenv('MAKE','make'ifplatform.system()!='Windows'else'mingw32-make',)cmd=[make]cmd.extend(compiler_options.compose(filename_in_msg=src.name))cmd.append(Path(exe_file).as_posix())sout=io.StringIO()try:do_command(cmd=cmd,cwd=cmdstan_path(),fd_out=sout)exceptRuntimeErrorase:sout.write(f'\n{str(e)}\n')compilation_failed=Truefinally:console=sout.getvalue()get_logger().debug('Console output:\n%s',console)ifnotcompilation_failed:ifis_copied:shutil.copy(exe_file,exe_target)get_logger().info('compiled model executable: %s',exe_target)if'Warning'inconsole:lines=console.split('\n')warnings=[xforxinlinesifx.startswith('Warning')]get_logger().warning('Stan compiler has produced %d warnings:',len(warnings),)get_logger().warning(console)ifcompilation_failed:if'PCH'inconsoleor'precompiled header'inconsole:get_logger().warning("CmdStan's precompiled header (PCH) files ""may need to be rebuilt.""Please run cmdstanpy.rebuild_cmdstan().\n""If the issue persists please open a bug report")raiseValueError(f"Failed to compile Stan model '{src}'. "f"Console:\n{console}")returnstr(exe_target)
[docs]defformat_stan_file(stan_file:Union[str,os.PathLike],*,overwrite_file:bool=False,canonicalize:Union[bool,str,Iterable[str]]=False,max_line_length:int=78,backup:bool=True,stanc_options:Optional[Dict[str,Any]]=None,)->None:""" Run stanc's auto-formatter on the model code. Either saves directly back to the file or prints for inspection :param stan_file: Path to Stan program file. :param overwrite_file: If True, save the updated code to disk, rather than printing it. By default False :param canonicalize: Whether or not the compiler should 'canonicalize' the Stan model, removing things like deprecated syntax. Default is False. If True, all canonicalizations are run. If it is a list of strings, those options are passed to stanc (new in Stan 2.29) :param max_line_length: Set the wrapping point for the formatter. The default value is 78, which wraps most lines by the 80th character. :param backup: If True, create a stanfile.bak backup before writing to the file. Only disable this if you're sure you have other copies of the file or are using a version control system like Git. :param stanc_options: Additional options to pass to the stanc compiler. """stan_file=Path(stan_file).resolve()ifnotstan_file.exists():raiseValueError(f'File does not exist: {stan_file}')try:cmd=([os.path.join(cmdstan_path(),'bin','stanc'+EXTENSION)]# handle include-paths, allow-undefined etc+CompilerOptions(stanc_options=stanc_options).compose_stanc(None)+[str(stan_file)])ifcanonicalize:ifcmdstan_version_before(2,29):ifisinstance(canonicalize,bool):cmd.append('--print-canonical')else:raiseValueError("Invalid arguments passed for current CmdStan"+" version({})\n".format(cmdstan_version()or"Unknown")+"--canonicalize requires 2.29 or higher")else:ifisinstance(canonicalize,str):cmd.append('--canonicalize='+canonicalize)elifisinstance(canonicalize,Iterable):cmd.append('--canonicalize='+','.join(canonicalize))else:cmd.append('--print-canonical')# before 2.29, having both --print-canonical# and --auto-format printed twiceifnot(cmdstan_version_before(2,29)andcanonicalize):cmd.append('--auto-format')ifnotcmdstan_version_before(2,29):cmd.append(f'--max-line-length={max_line_length}')elifmax_line_length!=78:raiseValueError("Invalid arguments passed for current CmdStan version"+" ({})\n".format(cmdstan_version()or"Unknown")+"--max-line-length requires 2.29 or higher")out=subprocess.run(cmd,capture_output=True,text=True,check=True)ifout.stderr:get_logger().warning(out.stderr)result=out.stdoutifoverwrite_file:ifresult:ifbackup:shutil.copyfile(stan_file,str(stan_file)+'.bak-'+datetime.now().strftime("%Y%m%d%H%M%S"),)stan_file.write_text(result)else:print(result)except(ValueError,RuntimeError)ase:raiseRuntimeError("Stanc formatting failed")frome