├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── check.yml ├── setup.py ├── tests ├── conftest.py ├── test_config.py ├── test_conda.py └── test_conda_env.py ├── docs └── index.md ├── tox_conda ├── __init__.py ├── env_activator.py └── plugin.py ├── mkdocs.yml ├── MANIFEST.in ├── pyproject.toml ├── .gitignore ├── CHANGES.rst ├── LICENSE ├── setup.cfg ├── .pre-commit-config.yaml ├── tox.ini └── README.rst /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gaborbernat 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from tox._pytestplugin import * # noqa 2 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to tox-conda 2 | 3 | Make tox cooperate with conda 4 | -------------------------------------------------------------------------------- /tox_conda/__init__.py: -------------------------------------------------------------------------------- 1 | from .version import version as __version__ 2 | 3 | __all__ = ("__version__",) 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: tox-conda 2 | site_description: Make tox cooperate with conda 3 | site_author: Oliver Bestwalter 4 | 5 | theme: readthedocs 6 | 7 | repo_url: https://github.com/tox-dev/tox-conda 8 | 9 | pages: 10 | - Home: index.md 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include CHANGES.rst 4 | 5 | include setup.cfg 6 | 7 | recursive-include docs * 8 | recursive-include tests *.py 9 | 10 | prune build 11 | prune docs/_build 12 | prune docs/api 13 | 14 | global-exclude *.pyc *.o 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 44", 4 | "wheel >= 0.30", 5 | "setuptools_scm[toml]>=3.4", 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | 9 | [tool.setuptools_scm] 10 | write_to = "tox_conda/version.py" 11 | 12 | [tool.black] 13 | line-length = 99 14 | 15 | [tool.isort] 16 | line_length = 99 17 | profile = "black" 18 | known_first_party = ["tox_conda"] 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python 2 | *.pyc 3 | *.pyo 4 | *.swp 5 | __pycache__ 6 | .eggs 7 | 8 | 9 | # packaging folders 10 | tox_conda/version.py 11 | /build/ 12 | /dist/ 13 | /tox_conda.egg-info 14 | 15 | # tox working folder 16 | /.tox 17 | 18 | # IDEs 19 | /.idea 20 | /.vscode 21 | 22 | # tools 23 | /.*_cache 24 | .dmypy.json 25 | 26 | # documentation 27 | /docs/_draft.rst 28 | 29 | # release 30 | credentials.json 31 | 32 | pip-wheel-metadata 33 | .DS_Store 34 | .coverage.* 35 | Dockerfile 36 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 0.4.1 (2020-11-10) 2 | ------------------ 3 | 4 | - Fix: Environments always recreated, except for version matching env interpreter 5 | 6 | 0.4.0 (2020-11-08) 7 | ------------------ 8 | 9 | - support of conda-spec.txt and conda-env.yml files 10 | 11 | 0.3.0 (2020-11-08) 12 | ------------------ 13 | 14 | - Document options within the readme. 15 | - Fix conda not found on Windows. 16 | - Don't display progress bar during conda install. 17 | 18 | 0.2.0 (2019-03-29) 19 | ------------------ 20 | 21 | - Update to enable compatibility with ``tox-3.8``. 22 | 23 | 24 | 0.1.0 (2018-12-06) 25 | ------------------ 26 | 27 | - Initial release. 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 Oliver Bestwalter 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = tox_conda 3 | description = tox plugin that provides integration with conda 4 | long_description = file: README.rst 5 | long_description_content_type = text/x-rst 6 | url = https://github.com/tox-dev/tox-conda 7 | author = Daniel R. D'Avella 8 | author_email = ddavella@stsci.edu 9 | maintainer = Bernat Gabor 10 | maintainer_email = gaborjbernat@gmail.com 11 | license = MIT 12 | license_file = LICENSE 13 | classifiers = 14 | Development Status :: 4 - Beta 15 | Framework :: tox 16 | Intended Audience :: Developers 17 | License :: OSI Approved :: MIT License 18 | Operating System :: OS Independent 19 | Programming Language :: Python 20 | Programming Language :: Python :: 3 21 | Programming Language :: Python :: 3 :: Only 22 | Programming Language :: Python :: Implementation :: CPython 23 | Topic :: Software Development :: Testing 24 | 25 | [options] 26 | packages = find: 27 | install_requires = 28 | ruamel.yaml>=0.15.0,<0.18 29 | tox>=3.8.1,<4 30 | python_requires = >=3.5 31 | 32 | [options.packages.find] 33 | exclude = tests 34 | 35 | [options.entry_points] 36 | tox = 37 | conda = tox_conda.plugin 38 | 39 | [tool:pytest] 40 | testpaths = tests 41 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-ast 6 | - id: check-builtin-literals 7 | - id: check-docstring-first 8 | - id: check-merge-conflict 9 | - id: check-yaml 10 | - id: check-toml 11 | - id: debug-statements 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | - repo: https://github.com/asottile/pyupgrade 15 | rev: v3.3.1 16 | hooks: 17 | - id: pyupgrade 18 | args: 19 | - --py3-plus 20 | - repo: https://github.com/PyCQA/isort 21 | rev: 5.12.0 22 | hooks: 23 | - id: isort 24 | - repo: https://github.com/psf/black 25 | rev: 22.12.0 26 | hooks: 27 | - id: black 28 | args: 29 | - --safe 30 | - repo: https://github.com/PyCQA/flake8 31 | rev: 6.0.0 32 | hooks: 33 | - id: flake8 34 | additional_dependencies: 35 | - flake8-bugbear == 22.9.23 36 | - repo: https://github.com/asottile/setup-cfg-fmt 37 | rev: v2.2.0 38 | hooks: 39 | - id: setup-cfg-fmt 40 | args: 41 | - --min-py3-version 42 | - "3.5" 43 | - repo: https://github.com/tox-dev/tox-ini-fmt 44 | rev: 0.6.0 45 | hooks: 46 | - id: tox-ini-fmt 47 | args: 48 | - -p 49 | - fix 50 | - repo: https://github.com/asottile/blacken-docs 51 | rev: v1.12.1 52 | hooks: 53 | - id: blacken-docs 54 | additional_dependencies: 55 | - black==20.8b1 56 | - repo: https://github.com/pre-commit/pygrep-hooks 57 | rev: v1.9.0 58 | hooks: 59 | - id: rst-backticks 60 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | fix 4 | py39 5 | py38 6 | py37 7 | py36 8 | py35 9 | coverage 10 | pkg_meta 11 | isolated_build = true 12 | skip_missing_interpreters = true 13 | minversion = 3.14.0 14 | 15 | [testenv] 16 | description = run test suite under {basepython} 17 | setenv = 18 | COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}/.coverage.{envname}} 19 | PIP_DISABLE_VERSION_CHECK = 1 20 | PYTHONDONTWRITEBYTECODE = 1 21 | VIRTUALENV_DOWNLOAD = 0 22 | deps = 23 | pytest-timeout 24 | tox[testing]>=3.8.1,<4 25 | commands = 26 | pytest {posargs: \ 27 | --junitxml {toxworkdir}/junit.{envname}.xml --cov {envsitepackagesdir}/tox_conda --cov tests \ 28 | --cov-config=tox.ini --no-cov-on-fail --cov-report term-missing --cov-context=test \ 29 | --cov-report html:{envtmpdir}/htmlcov \ 30 | --cov-report xml:{toxworkdir}/coverage.{envname}.xml \ 31 | tests --timeout 180 --durations 5} 32 | 33 | [testenv:fix] 34 | description = format the code base to adhere to our styles, and complain about what we cannot do automatically 35 | passenv = 36 | HOMEPATH 37 | PROGRAMDATA 38 | skip_install = true 39 | deps = 40 | pre-commit>=2 41 | virtualenv<20.0.34 42 | extras = 43 | lint 44 | commands = 45 | pre-commit run --all-files --show-diff-on-failure 46 | python -c 'import pathlib; print("hint: run \{\} install to add checks as pre-commit hook".format(pathlib.Path(r"{envdir}") / "bin" / "pre-commit"))' 47 | 48 | [testenv:coverage] 49 | description = [run locally after tests]: combine coverage data and create report; 50 | generates a diff coverage against origin/master (can be changed by setting DIFF_AGAINST env var) 51 | passenv = 52 | DIFF_AGAINST 53 | setenv = 54 | COVERAGE_FILE = {toxworkdir}/.coverage 55 | skip_install = true 56 | deps = 57 | {[testenv]deps} 58 | coverage>=5 59 | diff_cover 60 | parallel_show_output = true 61 | commands = 62 | coverage combine 63 | coverage report -m 64 | coverage xml -o {toxworkdir}/coverage.xml 65 | coverage html -d {toxworkdir}/htmlcov 66 | diff-cover --compare-branch {env:DIFF_AGAINST:origin/master} {toxworkdir}/coverage.xml 67 | depends = 68 | py39 69 | py38 70 | py37 71 | py36 72 | py35 73 | 74 | [testenv:pkg_meta] 75 | description = check that the long description is valid 76 | basepython = python3.10 77 | skip_install = true 78 | deps = 79 | build>=0.0.4 80 | twine>=3 81 | commands = 82 | python -m build -o {envtmpdir} -s -w . 83 | twine check {envtmpdir}/* 84 | 85 | [testenv:dev] 86 | description = dev environment with all deps at {envdir} 87 | usedevelop = true 88 | commands = 89 | python -m pip list --format=columns 90 | python -c "print(r'{envpython}')" 91 | 92 | [flake8] 93 | max-line-length = 99 94 | ignore = E203 95 | 96 | [coverage:run] 97 | branch = true 98 | parallel = true 99 | 100 | [coverage:report] 101 | skip_covered = True 102 | show_missing = True 103 | exclude_lines = 104 | \#\s*pragma: no cover 105 | ^\s*raise AssertionError\b 106 | ^\s*raise NotImplementedError\b 107 | ^\s*return NotImplemented\b 108 | ^\s*raise$ 109 | ^if __name__ == ['"]__main__['"]:$ 110 | 111 | [coverage:paths] 112 | source = src/tox_conda 113 | .tox*/*/lib/python*/site-packages/tox_conda 114 | .tox*/pypy*/site-packages/tox_conda 115 | .tox*\*\Lib\site-packages\tox_conda 116 | */src/tox_conda 117 | *\src\tox_conda 118 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | on: 3 | push: 4 | pull_request: 5 | schedule: 6 | - cron: "0 8 * * *" 7 | 8 | concurrency: 9 | group: check-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | pre_commit: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.10' 20 | - uses: pre-commit/action@v3.0.0 21 | 22 | test: 23 | name: test ${{ matrix.py }} - ${{ matrix.os }} 24 | runs-on: ${{ matrix.os }}-latest 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | os: 29 | - Ubuntu 30 | - Windows 31 | - MacOs 32 | py: 33 | - '3.10' 34 | - 3.9 35 | - 3.8 36 | - 3.7 37 | - 3.6 38 | - 3.5 39 | steps: 40 | - name: setup python for tox 41 | uses: actions/setup-python@v4 42 | with: 43 | python-version: '3.10' 44 | - name: install tox 45 | run: python -m pip install tox 46 | - name: Setup Miniconda 47 | uses: conda-incubator/setup-miniconda@v2.2.0 48 | with: 49 | miniconda-version: "latest" 50 | python-version: ${{ matrix.py }} 51 | - uses: actions/checkout@v4 52 | - name: pick environment to run 53 | run: | 54 | import subprocess; import json; import os 55 | major, minor, impl = json.loads(subprocess.check_output(["python", "-c", "import json; import sys; import platform; print(json.dumps([sys.version_info[0], sys.version_info[1], platform.python_implementation()]));"], universal_newlines=True)) 56 | with open(os.environ['GITHUB_ENV'], 'a') as file_handler: 57 | file_handler.write('TOXENV=' + ("py" if impl == "CPython" else "pypy") + ("{}{}".format(major, minor) if impl == "CPython" else "3") + "\n") 58 | shell: python 59 | - name: setup test suite 60 | run: tox -vv --notest 61 | - name: run test suite 62 | run: tox --skip-pkg-install 63 | env: 64 | PYTEST_ADDOPTS: "-vv --durations=20" 65 | CI_RUN: "yes" 66 | DIFF_AGAINST: HEAD 67 | - name: rename coverage report file 68 | run: | 69 | import os; os.rename('.tox/coverage.{}.xml'.format(os.environ['TOXENV']), '.tox/coverage.xml') 70 | shell: python 71 | - uses: codecov/codecov-action@v3 72 | with: 73 | file: ./.tox/coverage.xml 74 | flags: tests 75 | name: ${{ matrix.py }} - ${{ matrix.os }} 76 | 77 | check: 78 | name: check ${{ matrix.tox_env }} - ${{ matrix.os }} 79 | runs-on: ${{ matrix.os }}-latest 80 | strategy: 81 | fail-fast: false 82 | matrix: 83 | os: 84 | - Windows 85 | - Ubuntu 86 | tox_env: 87 | - dev 88 | - pkg_meta 89 | exclude: 90 | - { os: windows, tox_env: pkg_meta } 91 | steps: 92 | - uses: actions/checkout@v4 93 | - name: setup Python '3.10' 94 | uses: actions/setup-python@v4 95 | with: 96 | python-version: '3.10' 97 | - name: install tox 98 | run: python -m pip install tox 99 | - name: run check for ${{ matrix.tox_env }} 100 | run: python -m tox -e ${{ matrix.tox_env }} 101 | env: 102 | UPGRADE_ADVISORY: "yes" 103 | 104 | publish: 105 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 106 | needs: [check, test, pre_commit] 107 | runs-on: ubuntu-latest 108 | steps: 109 | - name: setup python to build package 110 | uses: actions/setup-python@v4 111 | with: 112 | python-version: '3.10' 113 | - name: install pep517 114 | run: python -m pip install build 115 | - uses: actions/checkout@v4 116 | - name: build package 117 | run: python -m build -s -w . -o dist 118 | - name: publish to PyPi 119 | uses: pypa/gh-action-pypi-publish@master 120 | with: 121 | skip_existing: true 122 | user: __token__ 123 | password: ${{ secrets.pypi_password }} 124 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | def test_conda_deps(tmpdir, newconfig): 2 | config = newconfig( 3 | [], 4 | """ 5 | [tox] 6 | toxworkdir = {} 7 | [testenv:py1] 8 | deps= 9 | hello 10 | conda_deps= 11 | world 12 | something 13 | """.format( 14 | tmpdir 15 | ), 16 | ) 17 | 18 | assert len(config.envconfigs) == 1 19 | assert hasattr(config.envconfigs["py1"], "deps") 20 | assert hasattr(config.envconfigs["py1"], "conda_deps") 21 | assert len(config.envconfigs["py1"].conda_deps) == 2 22 | # For now, as a workaround, we temporarily add all conda dependencies to 23 | # deps as well. This allows tox to know whether an environment needs to be 24 | # updated or not. Eventually there may be a cleaner solution. 25 | assert len(config.envconfigs["py1"].deps) == 3 26 | assert "world" == config.envconfigs["py1"].conda_deps[0].name 27 | assert "something" == config.envconfigs["py1"].conda_deps[1].name 28 | 29 | 30 | def test_conda_env_and_spec(tmpdir, newconfig): 31 | config = newconfig( 32 | [], 33 | """ 34 | [tox] 35 | toxworkdir = {} 36 | [testenv:py1] 37 | conda_env = conda_env.yaml 38 | conda_spec = conda_spec.txt 39 | """.format( 40 | tmpdir 41 | ), 42 | ) 43 | 44 | assert len(config.envconfigs) == 1 45 | assert config.envconfigs["py1"].conda_env == tmpdir / "conda_env.yaml" 46 | assert config.envconfigs["py1"].conda_spec == tmpdir / "conda_spec.txt" 47 | # Conda env and spec files get added to deps to allow tox to detect changes. 48 | # Similar to conda_deps in the test above. 49 | assert hasattr(config.envconfigs["py1"], "deps") 50 | assert len(config.envconfigs["py1"].deps) == 2 51 | assert any(dep.name == tmpdir / "conda_env.yaml" for dep in config.envconfigs["py1"].deps) 52 | assert any(dep.name == tmpdir / "conda_spec.txt" for dep in config.envconfigs["py1"].deps) 53 | 54 | 55 | def test_no_conda_deps(tmpdir, newconfig): 56 | config = newconfig( 57 | [], 58 | """ 59 | [tox] 60 | toxworkdir = {} 61 | [testenv:py1] 62 | deps= 63 | hello 64 | """.format( 65 | tmpdir 66 | ), 67 | ) 68 | 69 | assert len(config.envconfigs) == 1 70 | assert hasattr(config.envconfigs["py1"], "deps") 71 | assert hasattr(config.envconfigs["py1"], "conda_deps") 72 | assert hasattr(config.envconfigs["py1"], "conda_channels") 73 | assert len(config.envconfigs["py1"].conda_deps) == 0 74 | assert len(config.envconfigs["py1"].conda_channels) == 0 75 | assert len(config.envconfigs["py1"].deps) == 1 76 | 77 | 78 | def test_conda_channels(tmpdir, newconfig): 79 | config = newconfig( 80 | [], 81 | """ 82 | [tox] 83 | toxworkdir = {} 84 | [testenv:py1] 85 | deps= 86 | hello 87 | conda_deps= 88 | something 89 | else 90 | conda_channels= 91 | conda-forge 92 | """.format( 93 | tmpdir 94 | ), 95 | ) 96 | 97 | assert len(config.envconfigs) == 1 98 | assert hasattr(config.envconfigs["py1"], "deps") 99 | assert hasattr(config.envconfigs["py1"], "conda_deps") 100 | assert hasattr(config.envconfigs["py1"], "conda_channels") 101 | assert len(config.envconfigs["py1"].conda_channels) == 1 102 | assert "conda-forge" in config.envconfigs["py1"].conda_channels 103 | 104 | 105 | def test_conda_force_deps(tmpdir, newconfig): 106 | config = newconfig( 107 | ["--force-dep=something<42.1"], 108 | """ 109 | [tox] 110 | toxworkdir = {} 111 | [testenv:py1] 112 | deps= 113 | hello 114 | conda_deps= 115 | something 116 | else 117 | conda_channels= 118 | conda-forge 119 | """.format( 120 | tmpdir 121 | ), 122 | ) 123 | 124 | assert len(config.envconfigs) == 1 125 | assert hasattr(config.envconfigs["py1"], "conda_deps") 126 | assert len(config.envconfigs["py1"].conda_deps) == 2 127 | assert "something<42.1" == config.envconfigs["py1"].conda_deps[0].name 128 | -------------------------------------------------------------------------------- /tox_conda/env_activator.py: -------------------------------------------------------------------------------- 1 | """Wrap the tox command for subprocess to activate the target anaconda env.""" 2 | import abc 3 | import os 4 | import shlex 5 | import tempfile 6 | from contextlib import contextmanager 7 | 8 | import tox 9 | 10 | 11 | class PopenInActivatedEnvBase(abc.ABC): 12 | """A base functor that wraps popen calls in an activated anaconda env.""" 13 | 14 | def __init__(self, venv, popen): 15 | self._venv = venv 16 | self.__popen = popen 17 | 18 | def __call__(self, cmd_args, **kwargs): 19 | wrapped_cmd_args = self._wrap_cmd_args(cmd_args) 20 | return self.__popen(wrapped_cmd_args, **kwargs) 21 | 22 | @abc.abstractmethod 23 | def _wrap_cmd_args(self, cmd_args): 24 | """Return the wrapped command arguments.""" 25 | 26 | 27 | class PopenInActivatedEnvPosix(PopenInActivatedEnvBase): 28 | """Wrap popen calls in an activated anaconda env for POSIX platforms. 29 | 30 | The command line to be executed are written to a temporary shell script. 31 | The shell script first activates the env. 32 | """ 33 | 34 | def __init__(self, venv, popen): 35 | super().__init__(venv, popen) 36 | self.__tmp_file = None 37 | 38 | def _wrap_cmd_args(self, cmd_args): 39 | conda_exe = shlex.quote(str(self._venv.envconfig.conda_exe)) 40 | envdir = shlex.quote(str(self._venv.envconfig.envdir)) 41 | 42 | conda_activate_cmd = 'eval "$({conda_exe} shell.posix activate {envdir})"'.format( 43 | conda_exe=conda_exe, envdir=envdir 44 | ) 45 | 46 | # Get a temporary file path. 47 | with tempfile.NamedTemporaryFile() as fp: 48 | self.__tmp_file = fp.name 49 | 50 | # Convert the command args to a command line. 51 | cmd_line = " ".join(map(shlex.quote, cmd_args)) 52 | 53 | with open(self.__tmp_file, "w") as fp: 54 | fp.writelines((conda_activate_cmd, "\n", cmd_line)) 55 | 56 | return ["/bin/sh", self.__tmp_file] 57 | 58 | def __del__(self): 59 | # Delete the eventual temporary script. 60 | if self.__tmp_file is not None: 61 | os.remove(self.__tmp_file) 62 | 63 | 64 | class PopenInActivatedEnvWindows(PopenInActivatedEnvBase): 65 | """Wrap popen call in an activated anaconda env for Windows. 66 | 67 | The shell is temporary forced to cmd.exe and the env is activated accordingly. 68 | This works without a script, the env activation command and the target 69 | command line are concatenated into a single command line. 70 | """ 71 | 72 | def __call__(self, cmd_args, **kwargs): 73 | # Backup COMSPEC before setting it to cmd.exe. 74 | old_comspec = os.environ.get("COMSPEC") 75 | self.__ensure_comspecs_is_cmd_exe() 76 | 77 | output = super().__call__(cmd_args, **kwargs) 78 | 79 | # Revert COMSPEC to its initial value. 80 | if old_comspec is None: 81 | del os.environ["COMSPEC"] 82 | else: 83 | os.environ["COMSPEC"] = old_comspec 84 | 85 | return output 86 | 87 | def _wrap_cmd_args(self, cmd_args): 88 | return ["conda.bat", "activate", str(self._venv.envconfig.envdir), "&&"] + cmd_args 89 | 90 | def __ensure_comspecs_is_cmd_exe(self): 91 | if os.path.basename(os.environ.get("COMSPEC", "")).lower() == "cmd.exe": 92 | return 93 | 94 | for env_var in ("SystemRoot", "windir"): 95 | root_path = os.environ.get(env_var) 96 | if root_path is None: 97 | continue 98 | cmd_exe = os.path.join(root_path, "System32", "cmd.exe") 99 | if os.path.isfile(cmd_exe): 100 | os.environ["COMSPEC"] = cmd_exe 101 | break 102 | else: 103 | tox.reporter.error("cmd.exe cannot be found") 104 | raise SystemExit(0) 105 | 106 | 107 | if tox.INFO.IS_WIN: 108 | PopenInActivatedEnv = PopenInActivatedEnvWindows 109 | else: 110 | PopenInActivatedEnv = PopenInActivatedEnvPosix 111 | 112 | 113 | @contextmanager 114 | def activate_env(venv, action=None): 115 | """Run a command in a temporary activated anaconda env.""" 116 | if action is None: 117 | initial_popen = venv.popen 118 | venv.popen = PopenInActivatedEnv(venv, initial_popen) 119 | else: 120 | initial_popen = action.via_popen 121 | action.via_popen = PopenInActivatedEnv(venv, initial_popen) 122 | 123 | yield 124 | 125 | if action is None: 126 | venv.popen = initial_popen 127 | else: 128 | action.via_popen = initial_popen 129 | -------------------------------------------------------------------------------- /tests/test_conda.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | import pytest 4 | import tox 5 | from tox.venv import VirtualEnv 6 | 7 | import tox_conda.plugin 8 | 9 | 10 | def test_conda(cmd, initproj): 11 | # The path has a blank space on purpose for testing issue #119. 12 | initproj( 13 | "pkg 1", 14 | filedefs={ 15 | "tox.ini": """ 16 | [tox] 17 | skipsdist=True 18 | [testenv] 19 | commands = python -c 'import sys, os; \ 20 | print(os.path.exists(os.path.join(sys.prefix, "conda-meta")))' 21 | """ 22 | }, 23 | ) 24 | result = cmd("-v", "-e", "py") 25 | result.assert_success() 26 | 27 | def index_of(m): 28 | return next((i for i, l in enumerate(result.outlines) if l.startswith(m)), None) 29 | 30 | assert any( 31 | "create --yes -p " in line 32 | for line in result.outlines[index_of("py create: ") + 1 : index_of("py installed: ")] 33 | ), result.output() 34 | 35 | assert result.outlines[-4] == "True" 36 | 37 | 38 | def test_conda_run_command(cmd, initproj): 39 | """Check that all the commands are run from an activated anaconda env. 40 | 41 | This is done by looking at the CONDA_PREFIX environment variable which contains 42 | the environment name. 43 | This variable is dumped to a file because commands_{pre,post} do not redirect 44 | their outputs. 45 | """ 46 | env_name = "foobar" 47 | initproj( 48 | "pkg-1", 49 | filedefs={ 50 | "tox.ini": """ 51 | [tox] 52 | skipsdist=True 53 | [testenv:{}] 54 | deps = 55 | pip >0,<999 56 | -r requirements.txt 57 | commands_pre = python -c "import os; open('commands_pre', 'w').write(os.environ['CONDA_PREFIX'])" 58 | commands = python -c "import os; open('commands', 'w').write(os.environ['CONDA_PREFIX'])" 59 | commands_post = python -c "import os; open('commands_post', 'w').write(os.environ['CONDA_PREFIX'])" 60 | """.format( # noqa: E501 61 | env_name 62 | ), 63 | "requirements.txt": "", 64 | }, 65 | ) 66 | 67 | result = cmd("-v", "-e", env_name) 68 | result.assert_success() 69 | 70 | for filename in ("commands_pre", "commands_post", "commands"): 71 | assert open(filename).read().endswith(env_name) 72 | 73 | # Run once again when the env creation hooks are not called. 74 | result = cmd("-v", "-e", env_name) 75 | result.assert_success() 76 | 77 | for filename in ("commands_pre", "commands_post", "commands"): 78 | assert open(filename).read().endswith(env_name) 79 | 80 | 81 | def test_missing_conda(cmd, initproj, monkeypatch): 82 | """Check that an error is shown when the conda executable is not found.""" 83 | 84 | initproj( 85 | "pkg-1", 86 | filedefs={ 87 | "tox.ini": """ 88 | [tox] 89 | require = tox-conda 90 | """, 91 | }, 92 | ) 93 | 94 | # Prevent conda from being found. 95 | original_which = shutil.which 96 | 97 | def which(path): # pragma: no cover 98 | if path.endswith("conda"): 99 | return None 100 | return original_which(path) 101 | 102 | monkeypatch.setattr(shutil, "which", which) 103 | 104 | result = cmd() 105 | 106 | assert result.outlines == ["ERROR: {}".format(tox_conda.plugin.MISSING_CONDA_ERROR)] 107 | 108 | 109 | def test_issue_115(cmd, initproj): 110 | """Verify that a conda activation script is sourced. 111 | 112 | https://docs.conda.io/projects/conda-build/en/latest/resources/activate-scripts.html 113 | """ 114 | if tox.INFO.IS_WIN: 115 | build_script_name = "build.bat" 116 | build_script = """ 117 | setlocal EnableDelayedExpansion 118 | mkdir %CONDA_PREFIX%\\etc\\conda\\activate.d 119 | copy activate.bat %CONDA_PREFIX%\\etc\\conda\\activate.d 120 | """ 121 | activate_script_name = "activate.bat" 122 | activate_script = """ 123 | set DUMMY=0 124 | """ 125 | commands_pre = "build.bat" 126 | 127 | else: 128 | build_script_name = "build.sh" 129 | build_script = """ 130 | mkdir -p "${CONDA_PREFIX}/etc/conda/activate.d" 131 | cp activate.sh "${CONDA_PREFIX}/etc/conda/activate.d" 132 | """ 133 | activate_script_name = "activate.sh" 134 | activate_script = """ 135 | export DUMMY=0 136 | """ 137 | commands_pre = "/bin/sh build.sh" 138 | 139 | initproj( 140 | "115", 141 | filedefs={ 142 | build_script_name: build_script, 143 | activate_script_name: activate_script, 144 | "tox.ini": """ 145 | [testenv] 146 | commands_pre = {} 147 | commands = python -c "import os; assert 'DUMMY' in os.environ" 148 | """.format( 149 | commands_pre 150 | ), 151 | }, 152 | ) 153 | 154 | result = cmd() 155 | result.assert_success() 156 | 157 | 158 | @pytest.mark.parametrize( 159 | "basepython,expected", 160 | [ 161 | ("python3.8", ["python=3.8"]), 162 | ("python3.9", ["python=3.9"]), 163 | ("python3.10", ["python=3.10"]), 164 | ("pypy3.8", ["pypy3.8", "pip"]), 165 | ("pypy3.9", ["pypy3.9", "pip"]), 166 | ("none", []), 167 | ("None", []), 168 | ], 169 | ) 170 | def test_python_packages(newconfig, mocksession, basepython, expected): 171 | config = newconfig( 172 | [], 173 | """ 174 | [testenv:test] 175 | basepython={} 176 | """.format( 177 | basepython 178 | ), 179 | ) 180 | venv = VirtualEnv(config.envconfigs["test"]) 181 | assert venv.path == config.envconfigs["test"].envdir 182 | 183 | with mocksession.newaction(venv.name, "getenv") as action: 184 | result = tox_conda.plugin.get_python_packages(config.envconfigs["test"], action) 185 | 186 | assert set(result) == set(expected) 187 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | tox-conda 2 | ========= 3 | 4 | .. image:: https://www.repostatus.org/badges/latest/wip.svg 5 | :alt: Project Status: WIP – Initial development is in progress, but there has not yet been a stable, usable release suitable for the public. 6 | :target: https://www.repostatus.org/#wip 7 | 8 | .. image:: https://img.shields.io/pypi/v/tox-conda.svg 9 | :target: https://pypi.org/project/tox-conda 10 | :alt: PyPI version 11 | 12 | .. image:: https://img.shields.io/pypi/pyversions/tox-conda.svg 13 | :target: https://pypi.org/project/tox-conda 14 | :alt: Python versions 15 | 16 | .. image:: https://github.com/tox-dev/tox-conda/workflows/check/badge.svg 17 | :target: https://github.com/tox-dev/tox-conda/actions?query=workflow%3Acheck+branch%3Amaster 18 | :alt: CI 19 | 20 | .. image:: https://codecov.io/gh/tox-dev/tox-conda/branch/master/graph/badge.svg?token=yYBhrEf4MN 21 | :target: https://codecov.io/gh/tox-dev/tox-conda 22 | :alt: Code coverage 23 | 24 | ``tox-conda`` is a plugin that provides integration with the `conda 25 | `_ package and environment manager for the `tox 26 | `__ automation tool. It's like having your cake and 27 | eating it, too! 28 | 29 | By default, ``tox`` creates isolated environments using `virtualenv 30 | `_ and installs dependencies from ``pip``. 31 | 32 | In contrast, when using the ``tox-conda`` plugin ``tox`` will use ``conda`` to create 33 | environments, and will install specified dependencies from ``conda``. This is 34 | useful for developers who rely on ``conda`` for environment management and 35 | package distribution but want to take advantage of the features provided by 36 | ``tox`` for test automation. 37 | 38 | ``tox-conda`` has not been tested with ``conda`` version below 4.5. 39 | 40 | Getting Started 41 | --------------- 42 | 43 | ``tox-conda`` can be used in one of two ways: by installing it globally and by 44 | enabling it on a per-project basis. When the plugin is installed globally, the 45 | default behavior of ``tox`` will be to use ``conda`` to create environments. To 46 | use it on a per-project basis instead, use ``tox``'s auto-provisioning feature 47 | to selectively enable the plugin. 48 | 49 | To enable the use of ``tox-conda`` by default, follow the `Installation`_ 50 | instructions. To use the plugin selectively, do not manually install it, but 51 | instead enable it by adding ``tox-conda`` as a provisioning requirement to a 52 | project's ``tox`` config: 53 | 54 | :: 55 | 56 | [tox] 57 | requires = tox-conda 58 | 59 | More information on auto-provisioning can be found in the `tox documentation 60 | `__. 61 | 62 | Installation 63 | ------------ 64 | 65 | The ``tox-conda`` package is available on ``pypi``. To install, simply use the 66 | following command: 67 | 68 | :: 69 | 70 | $ pip install tox-conda 71 | 72 | To install from source, first clone the project from `github 73 | `_: 74 | 75 | :: 76 | 77 | $ git clone https://github.com/tox-dev/tox-conda 78 | 79 | Then install it in your environment: 80 | 81 | :: 82 | 83 | $ cd tox-conda 84 | $ pip install . 85 | 86 | To install in `development 87 | mode `__:: 88 | 89 | $ pip install -e . 90 | 91 | The ``tox-conda`` plugin expects that ``tox`` and ``conda`` are already installed and 92 | available in your working environment. 93 | 94 | Usage 95 | ----- 96 | 97 | Details on ``tox`` usage can be found in the `tox documentation 98 | `_. 99 | 100 | With the plugin enabled and no other changes, the ``tox-conda`` plugin will use 101 | ``conda`` to create environments and use ``pip`` to install dependencies that are 102 | given in the ``tox.ini`` configuration file. 103 | 104 | ``tox-conda`` adds six additional (and optional) settings to the ``[testenv]`` 105 | section of configuration files: 106 | 107 | * ``conda_deps``, which is used to configure which dependencies are installed 108 | from ``conda`` instead of from ``pip``. All dependencies in ``conda_deps`` are 109 | installed before all dependencies in ``deps``. If not given, no dependencies 110 | will be installed using ``conda``. 111 | 112 | * ``conda_channels``, which specifies which channel(s) should be used for 113 | resolving ``conda`` dependencies. If not given, only the ``default`` channel will 114 | be used. 115 | 116 | * ``conda_spec``, which specifies a ``conda-spec.txt`` file that lists conda 117 | dependencies to install and will be combined with ``conda_deps`` (if given). These 118 | dependencies can be in a general from (e.g., ``numpy>=1.17.5``) or an explicit 119 | form (eg., https://conda.anaconda.org/conda-forge/linux-64/numpy-1.17.5-py38h95a1406_0.tar.bz2), 120 | *however*, if the ``@EXPLICIT`` header is in ``conda-spec.txt``, *all* general 121 | dependencies will be ignored, including those given in ``conda_deps``. 122 | 123 | * ``conda_env``, which specifies a ``conda-env.yml`` file to create a base conda 124 | environment for the test. The ``conda-env.yml`` file is self-contained and 125 | if the desired conda channels to use are not given, the default channels will be used. 126 | If the ``conda-env.yml`` specifies a python version it must be compatible with the ``basepython`` 127 | set for the tox env. A ``conda-env.yml`` specifying ``python>=3.8`` could for example be 128 | used with ``basepython`` set to ``py38``, ``py39`` or ``py310``. 129 | The above ``conda_deps``, ``conda_channels``, and ``conda_spec`` arguments, if used in 130 | conjunction with a ``conda-env.yml`` file, will be used to *update* the environment *after* the 131 | initial environment creation. 132 | 133 | * ``conda_create_args``, which is used to pass arguments to the command ``conda create``. 134 | The passed arguments are inserted in the command line before the python package. 135 | For instance, passing ``--override-channels`` will create more reproducible environments 136 | because the channels defined in the user's ``.condarc`` will not interfer. 137 | 138 | * ``conda_install_args``, which is used to pass arguments to the command ``conda install``. 139 | The passed arguments are inserted in the command line before the dependencies. 140 | For instance, passing ``--override-channels`` will create more reproducible environments 141 | because the channels defined in the user's ``.condarc`` will not interfer. 142 | 143 | ``tox-conda`` will usually install a python version compatible with your specified ``basepython`` 144 | to the conda environment. To disable this behavior set ``basepython`` to ``none``. 145 | 146 | If `mamba `_ is installed in the same environment as tox, 147 | you may use it instead of the ``conda`` executable by setting the environment variable 148 | ``CONDA_EXE=mamba`` in the shell where ``tox`` is called. 149 | 150 | An example configuration file is given below: 151 | 152 | :: 153 | 154 | [tox] 155 | envlist = 156 | {py35,py36,py37}-{stable,dev} 157 | 158 | [testenv] 159 | deps= 160 | pytest-sugar 161 | py35,py36: importlib_resources 162 | dev: git+git://github.com/numpy/numpy 163 | conda_deps= 164 | pytest<=3.8 165 | stable: numpy=1.15 166 | conda_channels= 167 | conda-forge 168 | conda_install_args= 169 | --override-channels 170 | commands= 171 | pytest {posargs} 172 | 173 | More information on ``tox`` configuration files can be found in the 174 | `documentation `_. 175 | 176 | Contributing 177 | ------------ 178 | Contributions are very welcome. Tests can be run with `tox`_, please ensure 179 | the coverage at least stays the same before you submit a pull request. 180 | 181 | License 182 | ------- 183 | 184 | Distributed under the terms of the `MIT`_ license, "tox-conda" is free and open source software 185 | 186 | Issues 187 | ------ 188 | 189 | If you encounter any problems, please `file an issue`_ along with a detailed description. 190 | 191 | .. _`Cookiecutter`: https://github.com/audreyr/cookiecutter 192 | .. _`@obestwalter`: https://github.com/tox-dev 193 | .. _`MIT`: http://opensource.org/licenses/MIT 194 | .. _`BSD-3`: http://opensource.org/licenses/BSD-3-Clause 195 | .. _`GNU GPL v3.0`: http://www.gnu.org/licenses/gpl-3.0.txt 196 | .. _`Apache Software License 2.0`: http://www.apache.org/licenses/LICENSE-2.0 197 | .. _`cookiecutter-tox-plugin`: https://github.com/tox-dev/cookiecutter-tox-plugin 198 | .. _`file an issue`: https://github.com/tox-dev/tox-conda/issues 199 | .. _`pytest`: https://github.com/pytest-dev/pytest 200 | .. _`tox`: https://tox.readthedocs.io/en/latest/ 201 | .. _`pip`: https://pypi.org/project/pip/ 202 | .. _`PyPI`: https://pypi.org 203 | -------------------------------------------------------------------------------- /tox_conda/plugin.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import os 3 | import re 4 | import shutil 5 | import subprocess 6 | import tempfile 7 | from pathlib import Path 8 | 9 | import pluggy 10 | import py.path 11 | import tox 12 | from ruamel.yaml import YAML 13 | from tox.config import DepConfig, DepOption, TestenvConfig 14 | from tox.venv import VirtualEnv 15 | 16 | from .env_activator import activate_env 17 | 18 | hookimpl = pluggy.HookimplMarker("tox") 19 | 20 | MISSING_CONDA_ERROR = "Cannot locate the conda executable." 21 | 22 | 23 | class CondaDepOption(DepOption): 24 | name = "conda_deps" 25 | help = "each line specifies a conda dependency in pip/setuptools format" 26 | 27 | 28 | def postprocess_path_option(testenv_config, value): 29 | if value == testenv_config.config.toxinidir: 30 | return None 31 | return value 32 | 33 | 34 | def get_python_packages(envconfig, action): 35 | if envconfig.basepython.lower() == "none": 36 | return [] 37 | 38 | # Try to use basepython 39 | match = re.match(r"(python|pypy)(\d)(?:\.(\d+))?(?:\.?(\d))?", envconfig.basepython) 40 | if match: 41 | groups = match.groups() 42 | version = groups[1] 43 | if groups[2]: 44 | version += ".{}".format(groups[2]) 45 | if groups[3]: 46 | version += ".{}".format(groups[3]) 47 | 48 | if groups[0] == "pypy": 49 | # PyPy doesn't pull pip as a dependency, so we need to manually specify it 50 | return ["pypy{}".format(version), "pip"] 51 | 52 | # First fallback 53 | elif envconfig.python_info.version_info: 54 | version = "{}.{}".format(*envconfig.python_info.version_info[:2]) 55 | 56 | # Second fallback 57 | else: 58 | code = "import sys; print('{}.{}'.format(*sys.version_info[:2]))" 59 | result = action.popen([envconfig.basepython, "-c", code], report_fail=True, returnout=True) 60 | version = result.decode("utf-8").strip() 61 | 62 | return ["python={}".format(version)] 63 | 64 | 65 | @hookimpl 66 | def tox_addoption(parser): 67 | parser.add_testenv_attribute( 68 | name="conda_env", 69 | type="path", 70 | help="specify a conda environment.yml file", 71 | postprocess=postprocess_path_option, 72 | ) 73 | parser.add_testenv_attribute( 74 | name="conda_spec", 75 | type="path", 76 | help="specify a conda spec-file.txt file", 77 | postprocess=postprocess_path_option, 78 | ) 79 | 80 | parser.add_testenv_attribute_obj(CondaDepOption()) 81 | 82 | parser.add_testenv_attribute( 83 | name="conda_channels", type="line-list", help="each line specifies a conda channel" 84 | ) 85 | 86 | parser.add_testenv_attribute( 87 | name="conda_install_args", 88 | type="line-list", 89 | help="each line specifies a conda install argument", 90 | ) 91 | 92 | parser.add_testenv_attribute( 93 | name="conda_create_args", 94 | type="line-list", 95 | help="each line specifies a conda create argument", 96 | ) 97 | 98 | 99 | @hookimpl 100 | def tox_configure(config): 101 | # This is a pretty cheesy workaround. It allows tox to consider changes to 102 | # the conda dependencies when it decides whether an existing environment 103 | # needs to be updated before being used. 104 | 105 | # Set path to the conda executable because it cannot be determined once 106 | # an env has already been created. 107 | conda_exe = find_conda() 108 | 109 | for envconfig in config.envconfigs.values(): 110 | # Make sure the right environment is activated. This works because we're 111 | # creating environments using the `-p/--prefix` option in `tox_testenv_create` 112 | envconfig.setenv["CONDA_DEFAULT_ENV"] = envconfig.setenv["TOX_ENV_DIR"] 113 | 114 | conda_deps = [DepConfig(str(name)) for name in envconfig.conda_deps] 115 | # Append filenames of additional dependency sources. tox will automatically hash 116 | # their contents to detect changes. 117 | if envconfig.conda_spec is not None: 118 | conda_deps.append(DepConfig(envconfig.conda_spec)) 119 | if envconfig.conda_env is not None: 120 | conda_deps.append(DepConfig(envconfig.conda_env)) 121 | envconfig.deps.extend(conda_deps) 122 | 123 | envconfig.conda_exe = conda_exe 124 | 125 | 126 | def find_conda(): 127 | # This should work if we're not already in an environment 128 | conda_exe = os.environ.get("_CONDA_EXE") 129 | if conda_exe: 130 | return conda_exe 131 | 132 | # This should work if we're in an active environment 133 | conda_exe = os.environ.get("CONDA_EXE") 134 | if conda_exe: 135 | return conda_exe 136 | 137 | path = shutil.which("conda") 138 | 139 | if path is None: 140 | _exit_on_missing_conda() 141 | 142 | try: 143 | subprocess.run([str(path), "-h"], stdout=subprocess.DEVNULL) 144 | except subprocess.CalledProcessError: 145 | _exit_on_missing_conda() 146 | 147 | return path 148 | 149 | 150 | def _exit_on_missing_conda(): 151 | tox.reporter.error(MISSING_CONDA_ERROR) 152 | raise SystemExit(0) 153 | 154 | 155 | def _run_conda_process(args, venv, action, cwd): 156 | redirect = tox.reporter.verbosity() < tox.reporter.Verbosity.DEBUG 157 | venv._pcall(args, venv=False, action=action, cwd=cwd, redirect=redirect) 158 | 159 | 160 | @hookimpl 161 | def tox_testenv_create(venv, action): 162 | tox.venv.cleanup_for_venv(venv) 163 | basepath = venv.path.dirpath() 164 | 165 | # Check for venv.envconfig.sitepackages and venv.config.alwayscopy here 166 | envdir = venv.envconfig.envdir 167 | python_packages = get_python_packages(venv.envconfig, action) 168 | 169 | if venv.envconfig.conda_env is not None: 170 | env_path = Path(venv.envconfig.conda_env) 171 | # conda env create does not have a --channel argument nor does it take 172 | # dependencies specifications (e.g., python=3.8). These must all be specified 173 | # in the conda-env.yml file 174 | yaml = YAML() 175 | env_file = yaml.load(env_path) 176 | for package in python_packages: 177 | env_file["dependencies"].append(package) 178 | 179 | tmp_env = tempfile.NamedTemporaryFile( 180 | dir=env_path.parent, 181 | prefix="tox_conda_tmp", 182 | suffix=".yaml", 183 | delete=False, 184 | ) 185 | yaml.dump(env_file, tmp_env) 186 | 187 | args = [ 188 | venv.envconfig.conda_exe, 189 | "env", 190 | "create", 191 | "-p", 192 | envdir, 193 | "--file", 194 | tmp_env.name, 195 | ] 196 | tmp_env.close() 197 | _run_conda_process(args, venv, action, basepath) 198 | Path(tmp_env.name).unlink() 199 | 200 | else: 201 | args = [venv.envconfig.conda_exe, "create", "--yes", "-p", envdir] 202 | for channel in venv.envconfig.conda_channels: 203 | args += ["--channel", channel] 204 | 205 | # Add end-user conda create args 206 | args += venv.envconfig.conda_create_args 207 | 208 | args += python_packages 209 | 210 | _run_conda_process(args, venv, action, basepath) 211 | 212 | venv.envconfig.conda_python_packages = python_packages 213 | 214 | # let the venv know about the target interpreter just installed in our conda env, otherwise 215 | # we'll have a mismatch later because tox expects the interpreter to be existing outside of 216 | # the env 217 | try: 218 | del venv.envconfig.config.interpreters.name2executable[venv.name] 219 | except KeyError: 220 | pass 221 | 222 | venv.envconfig.config.interpreters.get_executable(venv.envconfig) 223 | 224 | return True 225 | 226 | 227 | def install_conda_deps(venv, action, basepath, envdir): 228 | # Account for the fact that we have a list of DepOptions 229 | conda_deps = [str(dep.name) for dep in venv.envconfig.conda_deps] 230 | # Add the conda-spec.txt file to the end of the conda deps b/c any deps 231 | # after --file option(s) are ignored 232 | if venv.envconfig.conda_spec is not None: 233 | conda_deps.append("--file={}".format(venv.envconfig.conda_spec)) 234 | 235 | action.setactivity("installcondadeps", ", ".join(conda_deps)) 236 | 237 | # Install quietly to make the log cleaner 238 | args = [venv.envconfig.conda_exe, "install", "--quiet", "--yes", "-p", envdir] 239 | for channel in venv.envconfig.conda_channels: 240 | args += ["--channel", channel] 241 | 242 | # Add end-user conda install args 243 | args += venv.envconfig.conda_install_args 244 | 245 | # We include the python version in the conda requirements in order to make 246 | # sure that none of the other conda requirements inadvertently downgrade 247 | # python in this environment. If any of the requirements are in conflict 248 | # with the installed python version, installation will fail (which is what 249 | # we want). 250 | args += venv.envconfig.conda_python_packages + conda_deps 251 | 252 | _run_conda_process(args, venv, action, basepath) 253 | 254 | 255 | @hookimpl 256 | def tox_testenv_install_deps(venv, action): 257 | # Save the deps before we make temporary changes. 258 | saved_deps = copy.deepcopy(venv.envconfig.deps) 259 | 260 | num_conda_deps = len(venv.envconfig.conda_deps) 261 | if venv.envconfig.conda_spec is not None: 262 | num_conda_deps += 1 263 | 264 | if num_conda_deps > 0: 265 | install_conda_deps(venv, action, venv.path.dirpath(), venv.envconfig.envdir) 266 | 267 | # Account for the fact that we added the conda_deps to the deps list in 268 | # tox_configure (see comment there for rationale). We don't want them 269 | # to be present when we call pip install. 270 | if venv.envconfig.conda_env is not None: 271 | num_conda_deps += 1 272 | if num_conda_deps > 0: 273 | venv.envconfig.deps = venv.envconfig.deps[:-num_conda_deps] 274 | 275 | with activate_env(venv, action): 276 | tox.venv.tox_testenv_install_deps(venv=venv, action=action) 277 | 278 | # Restore the deps. 279 | venv.envconfig.deps = saved_deps 280 | 281 | return True 282 | 283 | 284 | @hookimpl 285 | def tox_get_python_executable(envconfig): 286 | if tox.INFO.IS_WIN: 287 | path = envconfig.envdir.join("python.exe") 288 | else: 289 | path = envconfig.envdir.join("bin", "python") 290 | if path.exists(): 291 | return path 292 | 293 | 294 | # Monkey patch TestenConfig get_envpython to fix tox behavior with tox-conda under windows 295 | def get_envpython(self): 296 | """Override get_envpython to handle windows where the interpreter in at the env root dir.""" 297 | original_envpython = self.__get_envpython() 298 | if original_envpython.exists(): 299 | return original_envpython 300 | if tox.INFO.IS_WIN: 301 | return self.envdir.join("python") 302 | 303 | 304 | TestenvConfig.__get_envpython = TestenvConfig.get_envpython 305 | TestenvConfig.get_envpython = get_envpython 306 | 307 | 308 | # Monkey patch TestenvConfig _venv_lookup to fix tox behavior with tox-conda under windows 309 | def venv_lookup(self, name): 310 | """Override venv_lookup to also look at the env root dir under windows.""" 311 | paths = [self.envconfig.envbindir] 312 | # In Conda environments on Windows, the Python executable is installed in 313 | # the top-level environment directory, as opposed to virtualenvs, where it 314 | # is installed in the Scripts directory. Tox assumes that looking in the 315 | # Scripts directory is sufficient, which is why this workaround is required. 316 | if tox.INFO.IS_WIN: 317 | paths += [self.envconfig.envdir] 318 | return py.path.local.sysfind(name, paths=paths) 319 | 320 | 321 | VirtualEnv._venv_lookup = venv_lookup 322 | 323 | 324 | @hookimpl(hookwrapper=True) 325 | def tox_runtest_pre(venv): 326 | with activate_env(venv): 327 | yield 328 | 329 | 330 | @hookimpl 331 | def tox_runtest(venv, redirect): 332 | with activate_env(venv): 333 | tox.venv.tox_runtest(venv, redirect) 334 | return True 335 | 336 | 337 | @hookimpl(hookwrapper=True) 338 | def tox_runtest_post(venv): 339 | with activate_env(venv): 340 | yield 341 | -------------------------------------------------------------------------------- /tests/test_conda_env.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import pathlib 4 | import re 5 | from unittest.mock import mock_open, patch 6 | 7 | import tox 8 | from ruamel.yaml import YAML 9 | from tox.venv import VirtualEnv 10 | 11 | from tox_conda.env_activator import PopenInActivatedEnv 12 | from tox_conda.plugin import tox_testenv_create, tox_testenv_install_deps 13 | 14 | 15 | def test_conda_create(newconfig, mocksession): 16 | config = newconfig( 17 | [], 18 | """ 19 | [testenv:py123] 20 | """, 21 | ) 22 | 23 | venv = VirtualEnv(config.envconfigs["py123"]) 24 | assert venv.path == config.envconfigs["py123"].envdir 25 | 26 | with mocksession.newaction(venv.name, "getenv") as action: 27 | tox_testenv_create(action=action, venv=venv) 28 | pcalls = mocksession._pcalls 29 | assert len(pcalls) >= 1 30 | call = pcalls[-1] 31 | assert "conda" in call.args[0] 32 | assert "create" == call.args[1] 33 | assert "--yes" == call.args[2] 34 | assert "-p" == call.args[3] 35 | assert venv.path == call.args[4] 36 | assert call.args[5].startswith("python=") 37 | 38 | 39 | def create_test_env(config, mocksession, envname): 40 | 41 | venv = VirtualEnv(config.envconfigs[envname]) 42 | with mocksession.newaction(venv.name, "getenv") as action: 43 | tox_testenv_create(action=action, venv=venv) 44 | pcalls = mocksession._pcalls 45 | assert len(pcalls) >= 1 46 | pcalls[:] = [] 47 | 48 | return venv, action, pcalls 49 | 50 | 51 | def test_install_deps_no_conda(newconfig, mocksession, monkeypatch): 52 | """Test installation using conda when no conda_deps are given""" 53 | # No longer remove the temporary script, so we can check its contents. 54 | monkeypatch.delattr(PopenInActivatedEnv, "__del__", raising=False) 55 | 56 | env_name = "py123" 57 | config = newconfig( 58 | [], 59 | """ 60 | [testenv:{}] 61 | deps= 62 | numpy 63 | -r requirements.txt 64 | astropy 65 | """.format( 66 | env_name 67 | ), 68 | ) 69 | 70 | config.toxinidir.join("requirements.txt").write("") 71 | 72 | venv, action, pcalls = create_test_env(config, mocksession, env_name) 73 | 74 | assert len(venv.envconfig.deps) == 3 75 | assert len(venv.envconfig.conda_deps) == 0 76 | 77 | tox_testenv_install_deps(action=action, venv=venv) 78 | 79 | assert len(pcalls) >= 1 80 | 81 | call = pcalls[-1] 82 | 83 | if tox.INFO.IS_WIN: 84 | script_lines = " ".join(call.args).split(" && ", maxsplit=1) 85 | pattern = r"conda\.bat activate .*{}".format(re.escape(env_name)) 86 | else: 87 | # Get the cmd args from the script. 88 | shell, cmd_script = call.args 89 | assert shell == "/bin/sh" 90 | with open(cmd_script) as stream: 91 | script_lines = stream.readlines() 92 | pattern = r"eval \"\$\(/.*/conda shell\.posix activate /.*/{}\)\"".format(env_name) 93 | 94 | assert re.match(pattern, script_lines[0]) 95 | 96 | cmd = script_lines[1].split() 97 | assert cmd[-6:] == ["-m", "pip", "install", "numpy", "-rrequirements.txt", "astropy"] 98 | 99 | 100 | def test_install_conda_deps(newconfig, mocksession): 101 | config = newconfig( 102 | [], 103 | """ 104 | [testenv:py123] 105 | deps= 106 | numpy 107 | astropy 108 | conda_deps= 109 | pytest 110 | asdf 111 | """, 112 | ) 113 | 114 | venv, action, pcalls = create_test_env(config, mocksession, "py123") 115 | 116 | assert len(venv.envconfig.conda_deps) == 2 117 | assert len(venv.envconfig.deps) == 2 + len(venv.envconfig.conda_deps) 118 | 119 | tox_testenv_install_deps(action=action, venv=venv) 120 | # We expect two calls: one for conda deps, and one for pip deps 121 | assert len(pcalls) >= 2 122 | call = pcalls[-2] 123 | conda_cmd = call.args 124 | assert "conda" in os.path.split(conda_cmd[0])[-1] 125 | assert conda_cmd[1:6] == ["install", "--quiet", "--yes", "-p", venv.path] 126 | # Make sure that python is explicitly given as part of every conda install 127 | # in order to avoid inadvertent upgrades of python itself. 128 | assert conda_cmd[6].startswith("python=") 129 | assert conda_cmd[7:9] == ["pytest", "asdf"] 130 | 131 | 132 | def test_install_conda_no_pip(newconfig, mocksession): 133 | config = newconfig( 134 | [], 135 | """ 136 | [testenv:py123] 137 | conda_deps= 138 | pytest 139 | asdf 140 | """, 141 | ) 142 | 143 | venv, action, pcalls = create_test_env(config, mocksession, "py123") 144 | 145 | assert len(venv.envconfig.conda_deps) == 2 146 | assert len(venv.envconfig.deps) == len(venv.envconfig.conda_deps) 147 | 148 | tox_testenv_install_deps(action=action, venv=venv) 149 | # We expect only one call since there are no true pip dependencies 150 | assert len(pcalls) >= 1 151 | 152 | # Just a quick sanity check for the conda install command 153 | call = pcalls[-1] 154 | conda_cmd = call.args 155 | assert "conda" in os.path.split(conda_cmd[0])[-1] 156 | assert conda_cmd[1:6] == ["install", "--quiet", "--yes", "-p", venv.path] 157 | 158 | 159 | def test_update(tmpdir, newconfig, mocksession): 160 | pkg = tmpdir.ensure("package.tar.gz") 161 | config = newconfig( 162 | [], 163 | """ 164 | [testenv:py123] 165 | deps= 166 | numpy 167 | astropy 168 | conda_deps= 169 | pytest 170 | asdf 171 | """, 172 | ) 173 | 174 | venv, action, pcalls = create_test_env(config, mocksession, "py123") 175 | tox_testenv_install_deps(action=action, venv=venv) 176 | 177 | venv.hook.tox_testenv_create = tox_testenv_create 178 | venv.hook.tox_testenv_install_deps = tox_testenv_install_deps 179 | with mocksession.newaction(venv.name, "update") as action: 180 | venv.update(action) 181 | venv.installpkg(pkg, action) 182 | 183 | 184 | def test_conda_spec(tmpdir, newconfig, mocksession): 185 | """Test environment creation when conda_spec given""" 186 | txt = tmpdir.join("conda-spec.txt") 187 | txt.write( 188 | """ 189 | pytest 190 | """ 191 | ) 192 | config = newconfig( 193 | [], 194 | """ 195 | [testenv:py123] 196 | conda_deps= 197 | numpy 198 | astropy 199 | conda_spec={} 200 | """.format( 201 | str(txt) 202 | ), 203 | ) 204 | venv, action, pcalls = create_test_env(config, mocksession, "py123") 205 | 206 | assert venv.envconfig.conda_spec 207 | assert len(venv.envconfig.conda_deps) == 2 208 | 209 | tox_testenv_install_deps(action=action, venv=venv) 210 | # We expect conda_spec to be appended to conda deps install 211 | assert len(pcalls) >= 1 212 | call = pcalls[-1] 213 | conda_cmd = call.args 214 | assert "conda" in os.path.split(conda_cmd[0])[-1] 215 | assert conda_cmd[1:6] == ["install", "--quiet", "--yes", "-p", venv.path] 216 | # Make sure that python is explicitly given as part of every conda install 217 | # in order to avoid inadvertent upgrades of python itself. 218 | assert conda_cmd[6].startswith("python=") 219 | assert conda_cmd[7:9] == ["numpy", "astropy"] 220 | assert conda_cmd[-1].startswith("--file") 221 | assert conda_cmd[-1].endswith("conda-spec.txt") 222 | 223 | 224 | def test_empty_conda_spec_and_env(tmpdir, newconfig, mocksession): 225 | """Test environment creation when empty conda_spec and conda_env.""" 226 | txt = tmpdir.join("conda-spec.txt") 227 | txt.write( 228 | """ 229 | pytest 230 | """ 231 | ) 232 | config = newconfig( 233 | [], 234 | """ 235 | [testenv:py123] 236 | conda_env= 237 | foo: path-to.yml 238 | conda_spec= 239 | foo: path-to.yml 240 | """, 241 | ) 242 | venv, _, _ = create_test_env(config, mocksession, "py123") 243 | 244 | assert venv.envconfig.conda_spec is None 245 | assert venv.envconfig.conda_env is None 246 | 247 | 248 | def test_conda_env(tmpdir, newconfig, mocksession): 249 | """Test environment creation when conda_env given""" 250 | yml = tmpdir.join("conda-env.yml") 251 | yml.write( 252 | """ 253 | name: tox-conda 254 | channels: 255 | - conda-forge 256 | - nodefaults 257 | dependencies: 258 | - numpy 259 | - astropy 260 | - pip: 261 | - pytest 262 | """ 263 | ) 264 | config = newconfig( 265 | [], 266 | """ 267 | [testenv:py123] 268 | conda_env={} 269 | """.format( 270 | str(yml) 271 | ), 272 | ) 273 | 274 | venv = VirtualEnv(config.envconfigs["py123"]) 275 | assert venv.path == config.envconfigs["py123"].envdir 276 | 277 | venv, action, pcalls = create_test_env(config, mocksession, "py123") 278 | assert venv.envconfig.conda_env 279 | 280 | mock_file = mock_open() 281 | with patch("tox_conda.plugin.tempfile.NamedTemporaryFile", mock_file): 282 | with patch.object(pathlib.Path, "unlink", autospec=True) as mock_unlink: 283 | with mocksession.newaction(venv.name, "getenv") as action: 284 | tox_testenv_create(action=action, venv=venv) 285 | mock_unlink.assert_called_once 286 | 287 | mock_file.assert_called_with(dir=tmpdir, prefix="tox_conda_tmp", suffix=".yaml", delete=False) 288 | 289 | pcalls = mocksession._pcalls 290 | assert len(pcalls) >= 1 291 | call = pcalls[-1] 292 | cmd = call.args 293 | assert "conda" in os.path.split(cmd[0])[-1] 294 | assert cmd[1:4] == ["env", "create", "-p"] 295 | assert venv.path == call.args[4] 296 | assert call.args[5].startswith("--file") 297 | assert cmd[6] == str(mock_file().name) 298 | 299 | yaml = YAML() 300 | tmp_env = yaml.load(mock_open_to_string(mock_file)) 301 | assert tmp_env["dependencies"][-1].startswith("python=") 302 | 303 | 304 | def test_conda_env_and_spec(tmpdir, newconfig, mocksession): 305 | """Test environment creation when conda_env and conda_spec are given""" 306 | yml = tmpdir.join("conda-env.yml") 307 | yml.write( 308 | """ 309 | name: tox-conda 310 | channels: 311 | - conda-forge 312 | - nodefaults 313 | dependencies: 314 | - numpy 315 | - astropy 316 | """ 317 | ) 318 | txt = tmpdir.join("conda-spec.txt") 319 | txt.write( 320 | """ 321 | pytest 322 | """ 323 | ) 324 | config = newconfig( 325 | [], 326 | """ 327 | [testenv:py123] 328 | conda_env={} 329 | conda_spec={} 330 | """.format( 331 | str(yml), str(txt) 332 | ), 333 | ) 334 | venv, action, pcalls = create_test_env(config, mocksession, "py123") 335 | 336 | assert venv.envconfig.conda_env 337 | assert venv.envconfig.conda_spec 338 | 339 | mock_file = mock_open() 340 | with patch("tox_conda.plugin.tempfile.NamedTemporaryFile", mock_file): 341 | with patch.object(pathlib.Path, "unlink", autospec=True) as mock_unlink: 342 | with mocksession.newaction(venv.name, "getenv") as action: 343 | tox_testenv_create(action=action, venv=venv) 344 | mock_unlink.assert_called_once 345 | 346 | mock_file.assert_called_with(dir=tmpdir, prefix="tox_conda_tmp", suffix=".yaml", delete=False) 347 | 348 | pcalls = mocksession._pcalls 349 | assert len(pcalls) >= 1 350 | call = pcalls[-1] 351 | cmd = call.args 352 | assert "conda" in os.path.split(cmd[0])[-1] 353 | assert cmd[1:4] == ["env", "create", "-p"] 354 | assert venv.path == call.args[4] 355 | assert call.args[5].startswith("--file") 356 | assert cmd[6] == str(mock_file().name) 357 | 358 | yaml = YAML() 359 | tmp_env = yaml.load(mock_open_to_string(mock_file)) 360 | assert tmp_env["dependencies"][-1].startswith("python=") 361 | 362 | with mocksession.newaction(venv.name, "getenv") as action: 363 | tox_testenv_install_deps(action=action, venv=venv) 364 | pcalls = mocksession._pcalls 365 | # We expect conda_spec to be appended to conda deps install 366 | assert len(pcalls) >= 1 367 | call = pcalls[-1] 368 | conda_cmd = call.args 369 | assert "conda" in os.path.split(conda_cmd[0])[-1] 370 | assert conda_cmd[1:6] == ["install", "--quiet", "--yes", "-p", venv.path] 371 | # Make sure that python is explicitly given as part of every conda install 372 | # in order to avoid inadvertent upgrades of python itself. 373 | assert conda_cmd[6].startswith("python=") 374 | assert conda_cmd[-1].startswith("--file") 375 | assert conda_cmd[-1].endswith("conda-spec.txt") 376 | 377 | 378 | def test_conda_install_args(newconfig, mocksession): 379 | config = newconfig( 380 | [], 381 | """ 382 | [testenv:py123] 383 | conda_deps= 384 | numpy 385 | conda_install_args= 386 | --override-channels 387 | """, 388 | ) 389 | 390 | venv, action, pcalls = create_test_env(config, mocksession, "py123") 391 | 392 | assert len(venv.envconfig.conda_install_args) == 1 393 | 394 | tox_testenv_install_deps(action=action, venv=venv) 395 | 396 | call = pcalls[-1] 397 | assert call.args[6] == "--override-channels" 398 | 399 | 400 | def test_conda_create_args(newconfig, mocksession): 401 | config = newconfig( 402 | [], 403 | """ 404 | [testenv:py123] 405 | conda_create_args= 406 | --override-channels 407 | """, 408 | ) 409 | 410 | venv = VirtualEnv(config.envconfigs["py123"]) 411 | assert venv.path == config.envconfigs["py123"].envdir 412 | 413 | with mocksession.newaction(venv.name, "getenv") as action: 414 | tox_testenv_create(action=action, venv=venv) 415 | pcalls = mocksession._pcalls 416 | assert len(pcalls) >= 1 417 | call = pcalls[-1] 418 | assert "conda" in call.args[0] 419 | assert "create" == call.args[1] 420 | assert "--yes" == call.args[2] 421 | assert "-p" == call.args[3] 422 | assert venv.path == call.args[4] 423 | assert call.args[5] == "--override-channels" 424 | assert call.args[6].startswith("python=") 425 | 426 | 427 | def test_verbosity(newconfig, mocksession): 428 | config = newconfig( 429 | [], 430 | """ 431 | [testenv:py1] 432 | conda_deps=numpy 433 | [testenv:py2] 434 | conda_deps=numpy 435 | """, 436 | ) 437 | 438 | venv, action, pcalls = create_test_env(config, mocksession, "py1") 439 | tox_testenv_install_deps(action=action, venv=venv) 440 | assert len(pcalls) == 1 441 | call = pcalls[0] 442 | assert "conda" in call.args[0] 443 | assert "install" == call.args[1] 444 | assert isinstance(call.stdout, io.IOBase) 445 | 446 | tox.reporter.update_default_reporter( 447 | tox.reporter.Verbosity.DEFAULT, tox.reporter.Verbosity.DEBUG 448 | ) 449 | venv, action, pcalls = create_test_env(config, mocksession, "py2") 450 | tox_testenv_install_deps(action=action, venv=venv) 451 | assert len(pcalls) == 1 452 | call = pcalls[0] 453 | assert "conda" in call.args[0] 454 | assert "install" == call.args[1] 455 | assert not isinstance(call.stdout, io.IOBase) 456 | 457 | 458 | def mock_open_to_string(mock): 459 | return "".join(call.args[0] for call in mock().write.call_args_list) 460 | --------------------------------------------------------------------------------