Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: [3.7, 3.8, 3.9, "3.10", "3.11"]
python-version: ["3.7.1 - 3.7.16", "3.8", "3.9", "3.10", "3.11"]
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
steps:
Expand Down
3 changes: 2 additions & 1 deletion cmdstanpy/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,8 @@ def compile(
return

compilation_failed = False
# if target path has space, use copy in a tmpdir (GNU-Make constraint)
# if target path has spaces or special characters, use a copy in a
# temporary directory (GNU-Make constraint)
with SanitizedOrTmpFilePath(self._stan_file) as (stan_file, is_copied):
exe_file = os.path.splitext(stan_file)[0] + EXTENSION

Expand Down
25 changes: 19 additions & 6 deletions cmdstanpy/utils/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import contextlib
import os
import platform
import re
import shutil
import tempfile
from typing import Any, Iterator, List, Mapping, Tuple, Union
Expand Down Expand Up @@ -169,11 +170,22 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore


class SanitizedOrTmpFilePath:
"""Context manager for tmpfiles, handles spaces in filepath."""
"""
Context manager for tmpfiles, handles special characters in filepath.
"""
UNIXISH_PATTERN = re.compile(r"[\s~]")
WINDOWS_PATTERN = re.compile(r"\s")

@classmethod
def _has_special_chars(cls, file_path: str) -> bool:
if platform.system() == "Windows":
return bool(cls.WINDOWS_PATTERN.search(file_path))
return bool(cls.UNIXISH_PATTERN.search(file_path))

def __init__(self, file_path: str):
self._tmpdir = None
if ' ' in os.path.abspath(file_path) and platform.system() == 'Windows':

if self._has_special_chars(os.path.abspath(file_path)):
base_path, file_name = os.path.split(os.path.abspath(file_path))
os.makedirs(base_path, exist_ok=True)
try:
Expand All @@ -183,12 +195,13 @@ def __init__(self, file_path: str):
except RuntimeError:
pass

if ' ' in os.path.abspath(file_path):
if self._has_special_chars(os.path.abspath(file_path)):
tmpdir = tempfile.mkdtemp()
if ' ' in tmpdir:
if self._has_special_chars(tmpdir):
raise RuntimeError(
'Unable to generate temporary path without spaces! \n'
+ 'Please move your stan file to location without spaces.'
'Unable to generate temporary path without spaces or '
'special characters! \n Please move your stan file to a '
'location without spaces or special characters.'
)

_, path = tempfile.mkstemp(suffix='.stan', dir=tmpdir)
Expand Down
12 changes: 12 additions & 0 deletions test/data/path~with~tilde/bernoulli_path_with_tilde.stan
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
data {
int<lower=0> N;
int<lower=0,upper=1> y[N];
}
parameters {
real<lower=0,upper=1> theta;
}
model {
theta ~ beta(1,1);
for (n in 1:N)
y[n] ~ bernoulli(theta);
}
30 changes: 17 additions & 13 deletions test/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,16 +426,18 @@ def test_model_compile() -> None:
assert exe_time == os.path.getmtime(model2.exe_file)


def test_model_compile_space() -> None:
@pytest.mark.parametrize("path", ["space in path", "tilde~in~path"])
def test_model_compile_special_char(path: str) -> None:
with tempfile.TemporaryDirectory(
prefix="cmdstanpy_testfolder_"
) as tmp_path:
path_with_space = os.path.join(tmp_path, "space in path")
os.makedirs(path_with_space, exist_ok=True)
path_with_special_char = os.path.join(tmp_path, path)
os.makedirs(path_with_special_char, exist_ok=True)
bern_stan_new = os.path.join(
path_with_space, os.path.split(BERN_STAN)[1]
path_with_special_char, os.path.split(BERN_STAN)[1]
)
bern_exe_new = os.path.join(path_with_space, os.path.split(BERN_EXE)[1])
bern_exe_new = os.path.join(path_with_special_char,
os.path.split(BERN_EXE)[1])
shutil.copyfile(BERN_STAN, bern_stan_new)
model = CmdStanModel(stan_file=bern_stan_new)

Expand All @@ -451,30 +453,32 @@ def test_model_compile_space() -> None:
assert exe_time == os.path.getmtime(model2.exe_file)


def test_model_includes_space() -> None:
@pytest.mark.parametrize("path", ["space in path", "tilde~in~path"])
def test_model_includes_special_char(path: str) -> None:
"""Test model with include file in path with spaces."""
stan = os.path.join(DATAFILES_PATH, 'bernoulli_include.stan')
stan_divide = os.path.join(DATAFILES_PATH, 'divide_real_by_two.stan')

with tempfile.TemporaryDirectory(
prefix="cmdstanpy_testfolder_"
) as tmp_path:
path_with_space = os.path.join(tmp_path, "space in path")
os.makedirs(path_with_space, exist_ok=True)
bern_stan_new = os.path.join(path_with_space, os.path.split(stan)[1])
path_with_special_char = os.path.join(tmp_path, path)
os.makedirs(path_with_special_char, exist_ok=True)
bern_stan_new = os.path.join(path_with_special_char,
os.path.split(stan)[1])
stan_divide_new = os.path.join(
path_with_space, os.path.split(stan_divide)[1]
path_with_special_char, os.path.split(stan_divide)[1]
)
shutil.copyfile(stan, bern_stan_new)
shutil.copyfile(stan_divide, stan_divide_new)

model = CmdStanModel(
stan_file=bern_stan_new,
stanc_options={'include-paths': path_with_space},
stanc_options={'include-paths': path_with_special_char},
)
assert "space in path" in str(model.exe_file)
assert path in str(model.exe_file)

assert "space in path" in model.src_info()['included_files'][0]
assert path in model.src_info()['included_files'][0]
assert (
"divide_real_by_two.stan" in model.src_info()['included_files'][0]
)
Expand Down
3 changes: 2 additions & 1 deletion test/test_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
[
'bernoulli.stan',
'bernoulli with space in name.stan',
'path with space/' + 'bernoulli_path_with_space.stan',
'path with space/bernoulli_path_with_space.stan',
'path~with~tilde/bernoulli_path_with_tilde.stan',
],
)
def test_bernoulli_good(stanfile: str):
Expand Down
23 changes: 19 additions & 4 deletions test/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ def test_default_path() -> None:
assert 'CMDSTAN' in os.environ


def test_non_spaces_location() -> None:
@pytest.mark.parametrize("bad_dir", ["bad dir", "bad~dir"])
@pytest.mark.parametrize("bad_name", ["bad name", "bad~name"])
def test_non_special_chars_location(bad_dir: str, bad_name: str) -> None:
with tempfile.TemporaryDirectory(
prefix="cmdstan_tests", dir=_TMPDIR
) as tmpdir:
Expand All @@ -86,10 +88,10 @@ def test_non_spaces_location() -> None:
assert not is_changed

# prepare files for test
bad_path = os.path.join(tmpdir, 'bad dir')
bad_path = os.path.join(tmpdir, bad_dir)
os.makedirs(bad_path, exist_ok=True)
stan = os.path.join(DATAFILES_PATH, 'bernoulli.stan')
stan_bad = os.path.join(bad_path, 'bad name.stan')
stan_bad = os.path.join(bad_path, bad_name)
shutil.copy(stan, stan_bad)

stan_copied = None
Expand All @@ -98,7 +100,20 @@ def test_non_spaces_location() -> None:
stan_copied = pth
assert os.path.exists(stan_copied)
assert ' ' not in stan_copied
assert is_changed

# Determine if the file should have been copied, i.e., we either
# are on a unix-ish system or on windows, the path contains a
# space, and there is no short path.
if platform.system() == 'Windows':
should_change = ' ' in bad_name or (
' ' in bad_path
and not os.path.exists(windows_short_path(bad_path))
)
else:
should_change = True
assert '~' not in stan_copied

assert is_changed == should_change
raise RuntimeError
except RuntimeError:
pass
Expand Down