stan-dev/cmdstanpy

Some tests fail when using the release tarball

iyanmv opened this issue · 5 comments

Summary:

Some tests fail when using the release tarball.

Description:

In order to create a package for a distribution, I download the latest release tarball, build it (or in this case create a wheel package file cmdstanpy-1.2.0-py3-none-any.whl) and then run the tests on an isolated environment that only installs that wheel tarball together with the required dependencies (cmdstan, numpy, pandas, tqdm and stanio).

I'm running the tests with:

PYTHONPATH="test_dir/$_site_packages:$PYTHONPATH" CMDSTAN="/opt/cmdstan" pytest test

And this are the results:

==> Starting check()...
============================= test session starts ==============================
platform linux -- Python 3.11.6, pytest-7.4.4, pluggy-1.3.0
rootdir: /build/python-cmdstanpy/src/cmdstanpy-1.2.0
collected 280 items

test/test_cmdstan_args.py ..................................             [ 12%]
test/test_compiler_opts.py ............                                  [ 16%]
test/test_compliance.py ......                                           [ 18%]
test/test_cxx_installation.py s.ss                                       [ 20%]
test/test_generate_quantities.py .F..F.F..F.F..F...F..F.                 [ 28%]
test/test_install_cmdstan.py ....                                        [ 29%]
test/test_laplace.py ......                                              [ 31%]
test/test_log_prob.py ....                                               [ 33%]
test/test_metadata.py .                                                  [ 33%]
test/test_model.py ............F...............s..                       [ 44%]
test/test_optimize.py ......................                             [ 52%]
test/test_pathfinder.py .....                                            [ 54%]
test/test_runset.py .......                                              [ 56%]
test/test_sample.py ....F.....F.........F........................F.F.F.. [ 75%]
....F                                                                    [ 77%]
test/test_utils.py ..............................sss.................    [ 95%]
test/test_variational.py ..............E                                 [100%]

==================================== ERRORS ====================================

Some tests fail to run because they assume that git is installed and that the directory is a git repo in order to delete compiled models and output results (see cleanup_test_files() from module conftest.py).

___________________ ERROR at teardown of test_serialization ____________________

    @pytest.fixture(scope='session', autouse=True)
    def cleanup_test_files():
        """Remove compiled models and output files after test run."""
        yield
>       subprocess.Popen(
            ['git', 'clean', '-fX', DATAFILES_PATH],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.STDOUT,
        )

test/conftest.py:15: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
/usr/lib/python3.11/subprocess.py:1026: in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Popen: returncode: 255 args: ['git', 'clean', '-fX', '/build/python-cmdstan...>
args = ['git', 'clean', '-fX', '/build/python-cmdstanpy/src/cmdstanpy-1.2.0/test/data']
executable = b'git', preexec_fn = None, close_fds = True, pass_fds = ()
cwd = None, env = None, startupinfo = None, creationflags = 0, shell = False
p2cread = -1, p2cwrite = -1, c2pread = -1, c2pwrite = 25, errread = -1
errwrite = 25, restore_signals = True, gid = None, gids = None, uid = None
umask = -1, start_new_session = False, process_group = -1

    def _execute_child(self, args, executable, preexec_fn, close_fds,
                       pass_fds, cwd, env,
                       startupinfo, creationflags, shell,
                       p2cread, p2cwrite,
                       c2pread, c2pwrite,
                       errread, errwrite,
                       restore_signals,
                       gid, gids, uid, umask,
                       start_new_session, process_group):
        """Execute program (POSIX version)"""
    
        if isinstance(args, (str, bytes)):
            args = [args]
        elif isinstance(args, os.PathLike):
            if shell:
                raise TypeError('path-like args is not allowed when '
                                'shell is true')
            args = [args]
        else:
            args = list(args)
    
        if shell:
            # On Android the default shell is at '/system/bin/sh'.
            unix_shell = ('/system/bin/sh' if
                      hasattr(sys, 'getandroidapilevel') else '/bin/sh')
            args = [unix_shell, "-c"] + args
            if executable:
                args[0] = executable
    
        if executable is None:
            executable = args[0]
    
        sys.audit("subprocess.Popen", executable, args, cwd, env)
    
        if (_USE_POSIX_SPAWN
                and os.path.dirname(executable)
                and preexec_fn is None
                and not close_fds
                and not pass_fds
                and cwd is None
                and (p2cread == -1 or p2cread > 2)
                and (c2pwrite == -1 or c2pwrite > 2)
                and (errwrite == -1 or errwrite > 2)
                and not start_new_session
                and process_group == -1
                and gid is None
                and gids is None
                and uid is None
                and umask < 0):
            self._posix_spawn(args, executable, env, restore_signals,
                              p2cread, p2cwrite,
                              c2pread, c2pwrite,
                              errread, errwrite)
            return
    
        orig_executable = executable
    
        # For transferring possible exec failure from child to parent.
        # Data format: "exception name:hex errno:description"
        # Pickle is not used; it is complex and involves memory allocation.
        errpipe_read, errpipe_write = os.pipe()
        # errpipe_write must not be in the standard io 0, 1, or 2 fd range.
        low_fds_to_close = []
        while errpipe_write < 3:
            low_fds_to_close.append(errpipe_write)
            errpipe_write = os.dup(errpipe_write)
        for low_fd in low_fds_to_close:
            os.close(low_fd)
        try:
            try:
                # We must avoid complex work that could involve
                # malloc or free in the child process to avoid
                # potential deadlocks, thus we do all this here.
                # and pass it to fork_exec()
    
                if env is not None:
                    env_list = []
                    for k, v in env.items():
                        k = os.fsencode(k)
                        if b'=' in k:
                            raise ValueError("illegal environment variable name")
                        env_list.append(k + b'=' + os.fsencode(v))
                else:
                    env_list = None  # Use execv instead of execve.
                executable = os.fsencode(executable)
                if os.path.dirname(executable):
                    executable_list = (executable,)
                else:
                    # This matches the behavior of os._execvpe().
                    executable_list = tuple(
                        os.path.join(os.fsencode(dir), executable)
                        for dir in os.get_exec_path(env))
                fds_to_keep = set(pass_fds)
                fds_to_keep.add(errpipe_write)
                self.pid = _fork_exec(
                        args, executable_list,
                        close_fds, tuple(sorted(map(int, fds_to_keep))),
                        cwd, env_list,
                        p2cread, p2cwrite, c2pread, c2pwrite,
                        errread, errwrite,
                        errpipe_read, errpipe_write,
                        restore_signals, start_new_session,
                        process_group, gid, gids, uid, umask,
                        preexec_fn, _USE_VFORK)
                self._child_created = True
            finally:
                # be sure the FD is closed no matter what
                os.close(errpipe_write)
    
            self._close_pipe_fds(p2cread, p2cwrite,
                                 c2pread, c2pwrite,
                                 errread, errwrite)
    
            # Wait for exec to fail or succeed; possibly raising an
            # exception (limited in size)
            errpipe_data = bytearray()
            while True:
                part = os.read(errpipe_read, 50000)
                errpipe_data += part
                if not part or len(errpipe_data) > 50000:
                    break
        finally:
            # be sure the FD is closed no matter what
            os.close(errpipe_read)
    
        if errpipe_data:
            try:
                pid, sts = os.waitpid(self.pid, 0)
                if pid == self.pid:
                    self._handle_exitstatus(sts)
                else:
                    self.returncode = sys.maxsize
            except ChildProcessError:
                pass
    
            try:
                exception_name, hex_errno, err_msg = (
                        errpipe_data.split(b':', 2))
                # The encoding here should match the encoding
                # written in by the subprocess implementations
                # like _posixsubprocess
                err_msg = err_msg.decode()
            except ValueError:
                exception_name = b'SubprocessError'
                hex_errno = b'0'
                err_msg = 'Bad exception data from child: {!r}'.format(
                              bytes(errpipe_data))
            child_exception_type = getattr(
                    builtins, exception_name.decode('ascii'),
                    SubprocessError)
            if issubclass(child_exception_type, OSError) and hex_errno:
                errno_num = int(hex_errno, 16)
                child_exec_never_called = (err_msg == "noexec")
                if child_exec_never_called:
                    err_msg = ""
                    # The error must be from chdir(cwd).
                    err_filename = cwd
                else:
                    err_filename = orig_executable
                if errno_num != 0:
                    err_msg = os.strerror(errno_num)
>               raise child_exception_type(errno_num, err_msg, err_filename)
E               FileNotFoundError: [Errno 2] No such file or directory: 'git'

/usr/lib/python3.11/subprocess.py:1950: FileNotFoundError
----------------------------- Captured stderr call -----------------------------
13:25:54 - cmdstanpy - INFO - Chain [1] start processing
13:25:54 - cmdstanpy - INFO - Chain [1] done processing
------------------------------ Captured log call -------------------------------
DEBUG    cmdstanpy:compilation.py:415 found newer exe file, not recompiling
DEBUG    cmdstanpy:model.py:2041 idx 0
DEBUG    cmdstanpy:model.py:2042 running CmdStan, num_threads: 1
DEBUG    cmdstanpy:model.py:2054 CmdStan args: ['/build/python-cmdstanpy/src/cmdstanpy-1.2.0/test/data/variational/eta_should_be_big', 'random', 'seed=999999', 'output', 'file=/tmp/tmpuie7rvkx/eta_should_be_bign_77t1tj/eta_should_be_big-20240118132554.csv', 'method=variational', 'algorithm=meanfield', 'adapt', 'engaged=1']
INFO     cmdstanpy:model.py:2057 Chain [1] start processing
INFO     cmdstanpy:model.py:2114 Chain [1] done processing
=================================== FAILURES ===================================
_____________________________ test_pd_xr_agreement _____________________________

    def test_pd_xr_agreement():
        # fitted_params sample - list of filenames
        goodfiles_path = os.path.join(DATAFILES_PATH, 'runset-good', 'bern')
        csv_files = []
        for i in range(4):
            csv_files.append('{}-{}.csv'.format(goodfiles_path, i + 1))
    
        # gq_model
        stan = os.path.join(DATAFILES_PATH, 'bernoulli_ppc.stan')
        model = CmdStanModel(stan_file=stan)
        jdata = os.path.join(DATAFILES_PATH, 'bernoulli.data.json')
    
        bern_gqs = model.generate_quantities(data=jdata, previous_fit=csv_files)
    
        draws_pd = bern_gqs.draws_pd(inc_sample=True)
>       draws_xr = bern_gqs.draws_xr(inc_sample=True)

Others fail because they assume xarray is installed, when it's not marked as a test dependency (only optional).

To solve the second issue I will simply mark xarray as a dependency instead of optional dependency. For the first, I could write a patch but I first want to hear from you if it makes sense. My idea is to modify the function cleanup_test_files() to check if git is installed and if DATAFILES_PATH is a git repo. If both are true, then the current code can be used. Otherwise, nothing is done.

Additional Information:

For context, this is the (provisional) PKGBUILD that I wrote:

# Maintainer: Iyán Méndez Veiga <me (at) iyanmv (dot) com>
pkgname=python-cmdstanpy
_name=${pkgname#python-}
pkgver=1.2.0
pkgrel=1
pkgdesc="A lightweight interface to Stan"
arch=('any')
url="https://github.com/stan-dev/cmdstanpy"
license=('BSD-3-Clause')
depends=(
    'cmdstan'
    'python-numpy'
    'python-pandas'
    'python-tqdm'
    'python-stanio'
    'python-xarray'
)
makedepends=(
    'python-build'
    'python-installer'
    'python-wheel'
)
checkdepends=('python-pytest')
source=("${_name}-${pkgver}.tar.gz::https://github.com/stan-dev/${_name}/archive/refs/tags/v${pkgver}.tar.gz")
b2sums=('34cd1fff7e93b78f3395662040ed3a17697cbdf0f478fd4279ddb7b3704e9887d44d99040059009a6999c61d6ee234105f1404fcdef27a1a3847164d595c73bb')

build() {
    cd "${_name}-${pkgver}"
    python -m build --wheel --no-isolation
}

check() {
    local _site_packages=$(python -c "import site; print(site.getsitepackages()[0])")
    cd "${_name}-${pkgver}"
    python -m installer --destdir=test_dir dist/*.whl
    PYTHONPATH="test_dir/$_site_packages:$PYTHONPATH" CMDSTAN="/opt/cmdstan" pytest test
}

package() {
    cd "${_name}-${pkgver}"
    python -m installer --destdir="$pkgdir" dist/*.whl
    install -D -m644 LICENSE.md "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
    # Delete useless python scripts
    rm -rf "${pkgdir}/usr/bin"
}

Current Version:

cmdstan 2.34.0 (with patch stan-dev/cmdstan#1239 included)
cmdstanpy 1.2.0

Here I attach the logs from pytest (removing the ones caused by missing xarray)
test_errors.txt

Hi @iyanmv -

The dependencies on both xarray and git in testing would be nice to make more clear. Your suggestions both sound good.

The other issues look like they are either the ones resolved by #723 (which will be released in CmdStanPy 1.2.1 after the CmdStan release dust settles, probably next week) or are caused by your CmdStan source files being in a location that does not have read/write access

Indeed that patch fixes one test, but not the rest. I think you are right that most of them are permission issues.

To be honest, I don't know how to package this without making too many changes. Are you aware of any distro offering this in their repos? I think the simplest solution for now is to copy the CmdStan directory to a writable temp path just for the tests.

I know very little about distro packaging, unfortunately. It is a restriction of CmdStan's current make-based build system that CmdStan needs to be able to write to its own directory, which I know makes several packaging standards unhappy. Your suggestion would probably fix the tests, but anyone actually trying to use CmdStanPy would get similar errors in their own code

Out of tree builds are one of the items on the wishlist for a new build system (probably based on CMake or something similar) but there's no timeline and not a ton of free effort to spare there, unfortunately. The make-based system works just barely enough that a replacement isn't mandatory, so it languishes on

Since it sounds like all the failures are accounted for, I'm going to go ahead and close this issue. Sorry it wasn't a bit more satisfyingly resolved!