Skip to content

Commit 2cf6120

Browse files
authored
Merge pull request #700 from stan-dev/feature/update-constructor-deprecations
Move compilation code, deprecate constructing a model without ever compiling
2 parents 13fe59f + 19ac74e commit 2cf6120

File tree

5 files changed

+203
-116
lines changed

5 files changed

+203
-116
lines changed

cmdstanpy/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def _cleanup_tmpdir() -> None:
2222

2323

2424
from ._version import __version__ # noqa
25+
from .compilation import compile_stan_file
2526
from .install_cmdstan import rebuild_cmdstan
2627
from .model import CmdStanModel
2728
from .stanfit import (
@@ -31,7 +32,6 @@ def _cleanup_tmpdir() -> None:
3132
CmdStanMLE,
3233
CmdStanPathfinder,
3334
CmdStanVB,
34-
InferenceMetadata,
3535
from_csv,
3636
)
3737
from .utils import (
@@ -49,14 +49,14 @@ def _cleanup_tmpdir() -> None:
4949
'cmdstan_path',
5050
'set_make_env',
5151
'install_cmdstan',
52+
'compile_stan_file',
5253
'CmdStanMCMC',
5354
'CmdStanMLE',
5455
'CmdStanGQ',
5556
'CmdStanVB',
5657
'CmdStanLaplace',
5758
'CmdStanPathfinder',
5859
'CmdStanModel',
59-
'InferenceMetadata',
6060
'from_csv',
6161
'write_stan_json',
6262
'show_versions',
Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@
22
Makefile options for stanc and C++ compilers
33
"""
44

5+
import io
6+
import json
57
import os
8+
import platform
9+
import shutil
10+
import subprocess
611
from copy import copy
712
from pathlib import Path
813
from typing import Any, Dict, Iterable, List, Optional, Union
914

1015
from cmdstanpy.utils import get_logger
16+
from cmdstanpy.utils.cmdstan import EXTENSION, cmdstan_path
17+
from cmdstanpy.utils.command import do_command
18+
from cmdstanpy.utils.filesystem import SanitizedOrTmpFilePath
1119

1220
STANC_OPTS = [
1321
'O',
@@ -258,8 +266,9 @@ def add(self, new_opts: "CompilerOptions") -> None: # noqa: disable=Q000
258266
else:
259267
for key, val in new_opts.stanc_options.items():
260268
if key == 'include-paths':
261-
if isinstance(val, Iterable) \
262-
and not isinstance(val, str):
269+
if isinstance(val, Iterable) and not isinstance(
270+
val, str
271+
):
263272
for path in val:
264273
self.add_include_path(str(path))
265274
else:
@@ -322,3 +331,146 @@ def compose(self, filename_in_msg: Optional[str] = None) -> List[str]:
322331
for key, val in self._cpp_options.items():
323332
opts.append(f'{key}={val}')
324333
return opts
334+
335+
336+
def src_info(
337+
stan_file: str, compiler_options: CompilerOptions
338+
) -> Dict[str, Any]:
339+
"""
340+
Get source info for Stan program file.
341+
342+
This function is used in the implementation of
343+
:meth:`CmdStanModel.src_info`, and should not be called directly.
344+
"""
345+
cmd = (
346+
[os.path.join(cmdstan_path(), 'bin', 'stanc' + EXTENSION)]
347+
# handle include-paths, allow-undefined etc
348+
+ compiler_options.compose_stanc(None)
349+
+ ['--info', str(stan_file)]
350+
)
351+
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
352+
if proc.returncode:
353+
raise ValueError(
354+
f"Failed to get source info for Stan model "
355+
f"'{stan_file}'. Console:\n{proc.stderr}"
356+
)
357+
result: Dict[str, Any] = json.loads(proc.stdout)
358+
return result
359+
360+
361+
def compile_stan_file(
362+
src: Union[str, Path],
363+
force: bool = False,
364+
stanc_options: Optional[Dict[str, Any]] = None,
365+
cpp_options: Optional[Dict[str, Any]] = None,
366+
user_header: OptionalPath = None,
367+
) -> str:
368+
"""
369+
Compile the given Stan program file. Translates the Stan code to
370+
C++, then calls the C++ compiler.
371+
372+
By default, this function compares the timestamps on the source and
373+
executable files; if the executable is newer than the source file, it
374+
will not recompile the file, unless argument ``force`` is ``True``
375+
or unless the compiler options have been changed.
376+
377+
:param src: Path to Stan program file.
378+
379+
:param force: When ``True``, always compile, even if the executable file
380+
is newer than the source file. Used for Stan models which have
381+
``#include`` directives in order to force recompilation when changes
382+
are made to the included files.
383+
384+
:param stanc_options: Options for stanc compiler.
385+
:param cpp_options: Options for C++ compiler.
386+
:param user_header: A path to a header file to include during C++
387+
compilation.
388+
"""
389+
390+
src = Path(src).resolve()
391+
if not src.exists():
392+
raise ValueError(f'stan file does not exist: {src}')
393+
394+
compiler_options = CompilerOptions(
395+
stanc_options=stanc_options,
396+
cpp_options=cpp_options,
397+
user_header=user_header,
398+
)
399+
compiler_options.validate()
400+
401+
exe_target = src.with_suffix(EXTENSION)
402+
if exe_target.exists():
403+
exe_time = os.path.getmtime(exe_target)
404+
included_files = [src]
405+
included_files.extend(
406+
src_info(str(src), compiler_options).get('included_files', [])
407+
)
408+
out_of_date = any(
409+
os.path.getmtime(included_file) > exe_time
410+
for included_file in included_files
411+
)
412+
if not out_of_date and not force:
413+
get_logger().debug('found newer exe file, not recompiling')
414+
return str(exe_target)
415+
416+
compilation_failed = False
417+
# if target path has spaces or special characters, use a copy in a
418+
# temporary directory (GNU-Make constraint)
419+
with SanitizedOrTmpFilePath(str(src)) as (stan_file, is_copied):
420+
exe_file = os.path.splitext(stan_file)[0] + EXTENSION
421+
422+
hpp_file = os.path.splitext(exe_file)[0] + '.hpp'
423+
if os.path.exists(hpp_file):
424+
os.remove(hpp_file)
425+
if os.path.exists(exe_file):
426+
get_logger().debug('Removing %s', exe_file)
427+
os.remove(exe_file)
428+
429+
get_logger().info(
430+
'compiling stan file %s to exe file %s',
431+
stan_file,
432+
exe_target,
433+
)
434+
435+
make = os.getenv(
436+
'MAKE',
437+
'make' if platform.system() != 'Windows' else 'mingw32-make',
438+
)
439+
cmd = [make]
440+
cmd.extend(compiler_options.compose(filename_in_msg=src.name))
441+
cmd.append(Path(exe_file).as_posix())
442+
443+
sout = io.StringIO()
444+
try:
445+
do_command(cmd=cmd, cwd=cmdstan_path(), fd_out=sout)
446+
except RuntimeError as e:
447+
sout.write(f'\n{str(e)}\n')
448+
compilation_failed = True
449+
finally:
450+
console = sout.getvalue()
451+
452+
get_logger().debug('Console output:\n%s', console)
453+
if not compilation_failed:
454+
if is_copied:
455+
shutil.copy(exe_file, exe_target)
456+
get_logger().info('compiled model executable: %s', exe_target)
457+
if 'Warning' in console:
458+
lines = console.split('\n')
459+
warnings = [x for x in lines if x.startswith('Warning')]
460+
get_logger().warning(
461+
'Stan compiler has produced %d warnings:',
462+
len(warnings),
463+
)
464+
get_logger().warning(console)
465+
if compilation_failed:
466+
if 'PCH' in console or 'precompiled header' in console:
467+
get_logger().warning(
468+
"CmdStan's precompiled header (PCH) files "
469+
"may need to be rebuilt."
470+
"Please run cmdstanpy.rebuild_cmdstan().\n"
471+
"If the issue persists please open a bug report"
472+
)
473+
raise ValueError(
474+
f"Failed to compile Stan model '{src}'. " f"Console:\n{console}"
475+
)
476+
return str(exe_target)

0 commit comments

Comments
 (0)