├── .editorconfig ├── .gitattributes ├── .github ├── issue_template.md ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── pyproject.toml ├── tests ├── __init__.py ├── test_vox.py └── test_xontrib.py └── xontrib ├── autovox.py ├── vox.py └── voxapi.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # this is the top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [*.{yml,json}] 15 | indent_size = 2 16 | 17 | [*.{md,markdown}] 18 | # don't trim trailing spaces because markdown syntax allows that 19 | trim_trailing_whitespace = false 20 | 21 | [{makefile,Makefile,MAKEFILE}] 22 | indent_style = tab 23 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.xsh text linguist-language=Python 2 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | Hi! Thank you for the awesome xontrib! I gave it a star! 2 | 3 | I've found that ... 4 | 5 | ## For community 6 | ⬇️ **Please click the 👍 reaction instead of leaving a `+1` or 👍 comment** 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Hi! Thank you for the awesome xontrib! I gave it a star! 2 | 3 | Please take a look at my PR that ... 4 | 5 | ## For community 6 | ⬇️ **Please click the 👍 reaction instead of leaving a `+1` or 👍 comment** 7 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # Release drafter configuration https://github.com/release-drafter/release-drafter#configuration 2 | # Emojis were chosen to match the https://gitmoji.carloscuesta.me/ 3 | 4 | name-template: "v$NEXT_PATCH_VERSION" 5 | tag-template: "v$NEXT_PATCH_VERSION" 6 | 7 | categories: 8 | - title: ":rocket: Features" 9 | labels: [enhancement, feature] 10 | - title: ":wrench: Fixes & Refactoring" 11 | labels: [bug, refactoring, bugfix, fix] 12 | - title: ":package: Build System & CI/CD" 13 | labels: [build, ci, testing] 14 | - title: ":boom: Breaking Changes" 15 | labels: [breaking] 16 | - title: ":pencil: Documentation" 17 | labels: [documentation] 18 | - title: ":arrow_up: Dependencies updates" 19 | labels: [dependencies] 20 | 21 | template: | 22 | ## What’s Changed 23 | $CHANGES 24 | ## :busts_in_silhouette: List of contributors 25 | $CONTRIBUTORS 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | testing: 9 | # reuse workflow definitions 10 | uses: xonsh/actions/.github/workflows/test-pip-xontrib.yml@main 11 | with: 12 | cache-dependency-path: pyproject.toml 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pycqa/isort 3 | rev: 5.12.0 4 | hooks: 5 | - id: isort 6 | args: [ 7 | "--filter-files", # skip files that are excluded in config file 8 | "--profile=black", 9 | "--skip=migrations" 10 | ] 11 | 12 | - repo: https://github.com/psf/black 13 | rev: 23.1.0 14 | hooks: 15 | - id: black 16 | language_version: python3.9 17 | 18 | - repo: https://github.com/pre-commit/mirrors-mypy 19 | rev: "v1.0.1" # Use the sha / tag you want to point at 20 | hooks: 21 | - id: mypy 22 | args: [ "--allow-untyped-globals", "--ignore-missing-imports" ] 23 | additional_dependencies: [ types-all ] 24 | 25 | - repo: https://github.com/pre-commit/pre-commit-hooks 26 | rev: v4.4.0 27 | hooks: 28 | - id: trailing-whitespace 29 | - id: check-yaml 30 | - id: check-toml 31 | - id: check-merge-conflict 32 | - id: debug-statements 33 | - id: check-added-large-files 34 | 35 | - repo: https://github.com/pycqa/flake8 36 | rev: "6.0.0" # pick a git hash / tag to point to 37 | hooks: 38 | - id: flake8 39 | args: [ "--ignore=E501,W503,W504,E203,E251,E266,E401,E126,E124,C901" ] 40 | additional_dependencies: [ 41 | 'flake8-bugbear', 42 | ] 43 | 44 | - repo: https://github.com/asottile/pyupgrade 45 | rev: v3.3.1 46 | hooks: 47 | - id: pyupgrade 48 | args: 49 | - "--py37-plus" 50 | - repo: https://github.com/compilerla/conventional-pre-commit 51 | rev: v2.1.1 52 | hooks: 53 | - id: conventional-pre-commit 54 | stages: [commit-msg] 55 | args: [] # optional: list of Conventional Commits types to allow 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023, xontrib-vox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Python virtual environment manager for xonsh shell. 3 |

4 | 5 |

6 | If you like the idea click ⭐ on the repo and tweet. 7 |

8 | 9 | 10 | ## Installation 11 | 12 | To install use pip: 13 | 14 | ```bash 15 | xpip install xontrib-vox 16 | # or: xpip install -U git+https://github.com/xonsh/xontrib-vox 17 | ``` 18 | 19 | ## Usage 20 | 21 | This package contains three xontribs: 22 | * `vox` - Python virtual environment manager for xonsh. 23 | * `autovox` - Manages automatic activation of virtual environments. 24 | * `voxapi` - API for Vox, the Python virtual environment manager for xonsh. 25 | 26 | ### vox 27 | 28 | Python virtual environment manager for xonsh. 29 | 30 | ```bash 31 | xontrib load vox 32 | vox --help 33 | ``` 34 | 35 | ### autovox 36 | 37 | Manages automatic activation of virtual environments. 38 | 39 | ```bash 40 | xontrib load autovox 41 | ``` 42 | 43 | This coordinates multiple automatic vox policies and deals with some of the 44 | mechanics of venv searching and chdir handling. 45 | 46 | This provides no interface for end users. 47 | 48 | Developers should look at XSH.builtins.events.autovox_policy 49 | 50 | ### voxapi 51 | 52 | API for Vox, the Python virtual environment manager for xonsh. 53 | 54 | ```bash 55 | xontrib load voxapi 56 | ``` 57 | 58 | Vox defines several events related to the life cycle of virtual environments: 59 | 60 | * `vox_on_create(env: str) -> None` 61 | * `vox_on_activate(env: str, path: pathlib.Path) -> None` 62 | * `vox_on_deactivate(env: str, path: pathlib.Path) -> None` 63 | * `vox_on_delete(env: str) -> None` 64 | 65 | 66 | ## Credits 67 | 68 | This package was created with [xontrib template](https://github.com/xonsh/xontrib-template). 69 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | 2 | [project] 3 | name = "xontrib-vox" 4 | version = "0.0.1" 5 | license = {file = "LICENSE"} 6 | description = "Python virtual environment manager for xonsh." 7 | classifiers = [ 8 | "Programming Language :: Python :: 3", 9 | "License :: OSI Approved :: MIT License", 10 | "Operating System :: OS Independent", 11 | "Topic :: System :: Shells", 12 | "Topic :: System :: System Shells", 13 | "Topic :: Terminals", 14 | ] 15 | requires-python = ">=3.8" 16 | dependencies = ["xonsh>=0.12.5"] 17 | authors = [ 18 | { name = "xontrib-vox", email = "no@no.no" }, 19 | ] 20 | [project.readme] 21 | file = "README.md" 22 | content-type = "text/markdown" 23 | 24 | 25 | 26 | 27 | 28 | [project.urls] 29 | 30 | Homepage = "https://github.com/xonsh/xontrib-vox" 31 | Documentation = "https://github.com/xonsh/xontrib-vox/blob/master/README.md" 32 | Code = "https://github.com/xonsh/xontrib-vox" 33 | "Issue tracker" = "https://github.com/xonsh/xontrib-vox/issues" 34 | 35 | 36 | 37 | 38 | [project.optional-dependencies] 39 | dev = ["pytest>=7.0", "pytest-subprocess"] 40 | 41 | 42 | 43 | [build-system] 44 | requires = [ 45 | "setuptools>=62", 46 | "wheel", # for bdist package distribution 47 | ] 48 | build-backend = "setuptools.build_meta" 49 | [tool.setuptools] 50 | 51 | packages = ["xontrib"] 52 | package-dir = {xontrib = "xontrib"} 53 | 54 | platforms = ["any"] 55 | include-package-data = false 56 | [tool.setuptools.package-data] 57 | 58 | xontrib = ["*.xsh"] 59 | 60 | 61 | 62 | [tool.isort] 63 | profile = "black" 64 | 65 | [tool.black] 66 | include = '\.pyi?$' 67 | force-exclude = ''' 68 | /( 69 | \.git 70 | | \.hg 71 | | \.mypy_cache 72 | | \.pytest_cache 73 | | \.tox 74 | | \.vscode 75 | | \.venv 76 | | _build 77 | | buck-out 78 | | build 79 | | dist 80 | | disk-cache.sqlite3 81 | )/ 82 | ''' 83 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xonsh/xontrib-vox/fe51b7b9a52adbf94ce0a22f824db6d3455b63f7/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_vox.py: -------------------------------------------------------------------------------- 1 | """Vox tests""" 2 | import io 3 | import os 4 | import pathlib 5 | import stat 6 | import subprocess as sp 7 | import sys 8 | import types 9 | from typing import TYPE_CHECKING 10 | 11 | import pytest 12 | from py.path import local 13 | from xonsh.platform import ON_WINDOWS 14 | from xonsh.pytest.tools import skip_if_on_conda, skip_if_on_msys 15 | 16 | from xontrib.voxapi import Vox, _get_vox_default_interpreter 17 | 18 | if TYPE_CHECKING: 19 | from pytest_subprocess import FakeProcess 20 | 21 | from xontrib.vox import VoxHandler 22 | 23 | 24 | @pytest.fixture 25 | def venv_home(tmpdir, xession): 26 | """Path where VENVs are created""" 27 | home = tmpdir / "venvs" 28 | home.ensure_dir() 29 | # Set up an isolated venv home 30 | xession.env["VIRTUALENV_HOME"] = str(home) 31 | return home 32 | 33 | 34 | @pytest.fixture 35 | def venv_proc(fake_process: "FakeProcess", venv_home): 36 | def version_handle(process): 37 | ver = str(sys.version).split()[0] 38 | process.stdout.write(f"Python {ver}") 39 | 40 | def venv_handle(process): 41 | env_path = local(process.args[3]) 42 | (env_path / "lib").ensure_dir() 43 | bin_path = env_path / ("Scripts" if ON_WINDOWS else "bin") 44 | 45 | (bin_path / "python").write("", ensure=True) 46 | (bin_path / "python.exe").write("", ensure=True) 47 | for file in bin_path.listdir(): 48 | st = os.stat(str(file)) 49 | os.chmod(str(file), st.st_mode | stat.S_IEXEC) 50 | 51 | for pip_name in ["pip", "pip.exe"]: 52 | fake_process.register( 53 | [str(bin_path / pip_name), "freeze", fake_process.any()], stdout="" 54 | ) 55 | 56 | # will be used by `vox runin` 57 | fake_process.register( 58 | [pip_name, "--version"], 59 | stdout=f"pip 22.0.4 from {env_path}/lib/python3.10/site-packages/pip (python 3.10)", 60 | ) 61 | fake_process.keep_last_process(True) 62 | return env_path 63 | 64 | def get_interpreters(): 65 | interpreter = _get_vox_default_interpreter() 66 | yield interpreter 67 | if sys.executable != interpreter: 68 | yield sys.executable 69 | 70 | for cmd in get_interpreters(): 71 | fake_process.register([cmd, "--version"], callback=version_handle) 72 | venv = (cmd, "-m", "venv") 73 | fake_process.register([*venv, fake_process.any(min=1)], callback=venv_handle) 74 | fake_process.keep_last_process(True) 75 | return fake_process 76 | 77 | 78 | @pytest.fixture 79 | def vox(xession, load_xontrib, venv_proc) -> "VoxHandler": 80 | """vox Alias function""" 81 | 82 | # Set up enough environment for xonsh to function 83 | xession.env["PWD"] = os.getcwd() 84 | xession.env["DIRSTACK_SIZE"] = 10 85 | xession.env["PATH"] = [] 86 | xession.env["XONSH_SHOW_TRACEBACK"] = True 87 | 88 | load_xontrib("vox") 89 | vox = xession.aliases["vox"] 90 | return vox 91 | 92 | 93 | @pytest.fixture 94 | def record_events(xession): 95 | class Listener: 96 | def __init__(self): 97 | self.last = None 98 | 99 | def listener(self, name): 100 | def _wrapper(**kwargs): 101 | self.last = (name,) + tuple(kwargs.values()) 102 | 103 | return _wrapper 104 | 105 | def __call__(self, *events: str): 106 | for name in events: 107 | event = getattr(xession.builtins.events, name) 108 | event(self.listener(name)) 109 | 110 | yield Listener() 111 | 112 | 113 | def test_vox_flow(xession, vox, record_events, venv_home): 114 | """ 115 | Creates a virtual environment, gets it, enumerates it, and then deletes it. 116 | """ 117 | 118 | record_events( 119 | "vox_on_create", "vox_on_delete", "vox_on_activate", "vox_on_deactivate" 120 | ) 121 | 122 | vox(["create", "spam"]) 123 | assert stat.S_ISDIR(venv_home.join("spam").stat().mode) 124 | assert record_events.last == ("vox_on_create", "spam") 125 | 126 | ve = vox.vox["spam"] 127 | assert ve.env == str(venv_home.join("spam")) 128 | assert os.path.isdir(ve.bin) 129 | 130 | assert "spam" in vox.vox 131 | assert "spam" in list(vox.vox) 132 | 133 | # activate 134 | vox(["activate", "spam"]) 135 | assert xession.env["VIRTUAL_ENV"] == vox.vox["spam"].env 136 | assert record_events.last == ("vox_on_activate", "spam", str(ve.env)) 137 | 138 | out = io.StringIO() 139 | # info 140 | vox(["info"], stdout=out) 141 | assert "spam" in out.getvalue() 142 | out.seek(0) 143 | 144 | # list 145 | vox(["list"], stdout=out) 146 | print(out.getvalue()) 147 | assert "spam" in out.getvalue() 148 | out.seek(0) 149 | 150 | # wipe 151 | vox(["wipe"], stdout=out) 152 | print(out.getvalue()) 153 | assert "Nothing to remove" in out.getvalue() 154 | out.seek(0) 155 | 156 | # deactivate 157 | vox(["deactivate"]) 158 | assert "VIRTUAL_ENV" not in xession.env 159 | assert record_events.last == ("vox_on_deactivate", "spam", str(ve.env)) 160 | 161 | # runin 162 | vox(["runin", "spam", "pip", "--version"], stdout=out) 163 | print(out.getvalue()) 164 | assert "spam" in out.getvalue() 165 | out.seek(0) 166 | 167 | # removal 168 | vox(["rm", "spam", "--force"]) 169 | assert not venv_home.join("spam").check() 170 | assert record_events.last == ("vox_on_delete", "spam") 171 | 172 | 173 | def test_activate_non_vox_venv(xession, vox, record_events, venv_proc, venv_home): 174 | """ 175 | Create a virtual environment using Python's built-in venv module 176 | (not in VIRTUALENV_HOME) and verify that vox can activate it correctly. 177 | """ 178 | xession.env["PATH"] = [] 179 | 180 | record_events("vox_on_activate", "vox_on_deactivate") 181 | 182 | with venv_home.as_cwd(): 183 | venv_dirname = "venv" 184 | sp.run([sys.executable, "-m", "venv", venv_dirname]) 185 | vox(["activate", venv_dirname]) 186 | vxv = vox.vox[venv_dirname] 187 | 188 | env = xession.env 189 | assert os.path.isabs(vxv.bin) 190 | assert env["PATH"][0] == vxv.bin 191 | assert os.path.isabs(vxv.env) 192 | assert env["VIRTUAL_ENV"] == vxv.env 193 | assert record_events.last == ( 194 | "vox_on_activate", 195 | venv_dirname, 196 | str(pathlib.Path(str(venv_home)) / venv_dirname), 197 | ) 198 | 199 | vox(["deactivate"]) 200 | assert not env["PATH"] 201 | assert "VIRTUAL_ENV" not in env 202 | assert record_events.last == ( 203 | "vox_on_deactivate", 204 | venv_dirname, 205 | str(pathlib.Path(str(venv_home)) / venv_dirname), 206 | ) 207 | 208 | 209 | @skip_if_on_msys 210 | @skip_if_on_conda 211 | def test_path(xession, vox, a_venv): 212 | """ 213 | Test to make sure Vox properly activates and deactivates by examining $PATH 214 | """ 215 | oldpath = list(xession.env["PATH"]) 216 | vox(["activate", a_venv.basename]) 217 | 218 | assert oldpath != xession.env["PATH"] 219 | 220 | vox.deactivate() 221 | 222 | assert oldpath == xession.env["PATH"] 223 | 224 | 225 | def test_crud_subdir(xession, venv_home, venv_proc): 226 | """ 227 | Creates a virtual environment, gets it, enumerates it, and then deletes it. 228 | """ 229 | 230 | vox = Vox(force_removals=True) 231 | vox.create("spam/eggs") 232 | assert stat.S_ISDIR(venv_home.join("spam", "eggs").stat().mode) 233 | 234 | ve = vox["spam/eggs"] 235 | assert ve.env == str(venv_home.join("spam", "eggs")) 236 | assert os.path.isdir(ve.bin) 237 | 238 | assert "spam/eggs" in vox 239 | assert "spam" not in vox 240 | 241 | # assert 'spam/eggs' in list(vox) # This is NOT true on Windows 242 | assert "spam" not in list(vox) 243 | 244 | del vox["spam/eggs"] 245 | 246 | assert not venv_home.join("spam", "eggs").check() 247 | 248 | 249 | def test_crud_path(xession, tmpdir, venv_proc): 250 | """ 251 | Creates a virtual environment, gets it, enumerates it, and then deletes it. 252 | """ 253 | tmp = str(tmpdir) 254 | 255 | vox = Vox(force_removals=True) 256 | vox.create(tmp) 257 | assert stat.S_ISDIR(tmpdir.join("lib").stat().mode) 258 | 259 | ve = vox[tmp] 260 | assert ve.env == str(tmp) 261 | assert os.path.isdir(ve.bin) 262 | 263 | del vox[tmp] 264 | 265 | assert not tmpdir.check() 266 | 267 | 268 | @skip_if_on_msys 269 | @skip_if_on_conda 270 | def test_reserved_names(xession, tmpdir): 271 | """ 272 | Tests that reserved words are disallowed. 273 | """ 274 | xession.env["VIRTUALENV_HOME"] = str(tmpdir) 275 | 276 | vox = Vox() 277 | with pytest.raises(ValueError): 278 | if ON_WINDOWS: 279 | vox.create("Scripts") 280 | else: 281 | vox.create("bin") 282 | 283 | with pytest.raises(ValueError): 284 | if ON_WINDOWS: 285 | vox.create("spameggs/Scripts") 286 | else: 287 | vox.create("spameggs/bin") 288 | 289 | 290 | @pytest.mark.parametrize("registered", [False, True]) 291 | def test_autovox(xession, vox, a_venv, load_xontrib, registered): 292 | """ 293 | Tests that autovox works 294 | """ 295 | from xonsh.dirstack import popd, pushd 296 | 297 | # Makes sure that event handlers are registered 298 | load_xontrib("autovox") 299 | 300 | env_name = a_venv.basename 301 | env_path = str(a_venv) 302 | 303 | # init properly 304 | assert vox.parser 305 | 306 | def policy(path, **_): 307 | if str(path) == env_path: 308 | return env_name 309 | 310 | if registered: 311 | xession.builtins.events.autovox_policy(policy) 312 | 313 | pushd([env_path]) 314 | value = env_name if registered else None 315 | assert vox.vox.active() == value 316 | popd([]) 317 | 318 | 319 | @pytest.fixture 320 | def create_venv(venv_proc): 321 | vox = Vox(force_removals=True) 322 | 323 | def wrapped(name): 324 | vox.create(name) 325 | return local(vox[name].env) 326 | 327 | return wrapped 328 | 329 | 330 | @pytest.fixture 331 | def venvs(venv_home, create_venv): 332 | """Create virtualenv with names venv0, venv1""" 333 | from xonsh.dirstack import popd, pushd 334 | 335 | pushd([str(venv_home)]) 336 | yield [create_venv(f"venv{idx}") for idx in range(2)] 337 | popd([]) 338 | 339 | 340 | @pytest.fixture 341 | def a_venv(create_venv): 342 | return create_venv("venv0") 343 | 344 | 345 | @pytest.fixture 346 | def patched_cmd_cache(xession, vox, monkeypatch): 347 | cc = xession.commands_cache 348 | 349 | def no_change(self, *_): 350 | return False, False 351 | 352 | monkeypatch.setattr(cc, "_check_changes", types.MethodType(no_change, cc)) 353 | bins = {path: (path, False) for path in _PY_BINS} 354 | monkeypatch.setattr(cc, "_cmds_cache", bins) 355 | yield cc 356 | 357 | 358 | _VENV_NAMES = {"venv1", "venv1/", "venv0/", "venv0"} 359 | if ON_WINDOWS: 360 | _VENV_NAMES = {"venv1\\", "venv0\\", "venv0", "venv1"} 361 | 362 | _HELP_OPTS = { 363 | "-h", 364 | "--help", 365 | } 366 | _PY_BINS = {"/bin/python2", "/bin/python3"} 367 | 368 | _VOX_NEW_OPTS = { 369 | "--ssp", 370 | "--system-site-packages", 371 | "--without-pip", 372 | }.union(_HELP_OPTS) 373 | 374 | if ON_WINDOWS: 375 | _VOX_NEW_OPTS.add("--symlinks") 376 | else: 377 | _VOX_NEW_OPTS.add("--copies") 378 | 379 | _VOX_RM_OPTS = {"-f", "--force"}.union(_HELP_OPTS) 380 | 381 | 382 | class TestVoxCompletions: 383 | @pytest.fixture 384 | def check(self, check_completer, xession, vox): 385 | def wrapped(cmd, positionals, options=None): 386 | for k in list(xession.completers): 387 | if k != "alias": 388 | xession.completers.pop(k) 389 | assert check_completer(cmd) == positionals 390 | xession.env["ALIAS_COMPLETIONS_OPTIONS_BY_DEFAULT"] = True 391 | if options: 392 | assert check_completer(cmd) == positionals.union(options) 393 | 394 | return wrapped 395 | 396 | @pytest.mark.parametrize( 397 | "args, positionals, opts", 398 | [ 399 | ( 400 | "vox", 401 | { 402 | "delete", 403 | "new", 404 | "remove", 405 | "del", 406 | "workon", 407 | "list", 408 | "exit", 409 | "info", 410 | "ls", 411 | "rm", 412 | "deactivate", 413 | "activate", 414 | "enter", 415 | "create", 416 | "project-get", 417 | "project-set", 418 | "runin", 419 | "runin-all", 420 | "toggle-ssp", 421 | "wipe", 422 | "upgrade", 423 | }, 424 | _HELP_OPTS, 425 | ), 426 | ( 427 | "vox create", 428 | set(), 429 | _VOX_NEW_OPTS.union( 430 | { 431 | "-a", 432 | "--activate", 433 | "--wp", 434 | "--without-pip", 435 | "-p", 436 | "--interpreter", 437 | "-i", 438 | "--install", 439 | "-l", 440 | "--link", 441 | "--link-project", 442 | "-r", 443 | "--requirements", 444 | "-t", 445 | "--temp", 446 | "--prompt", 447 | } 448 | ), 449 | ), 450 | ("vox activate", _VENV_NAMES, _HELP_OPTS.union({"-n", "--no-cd"})), 451 | ("vox rm", _VENV_NAMES, _VOX_RM_OPTS), 452 | ("vox rm venv1", _VENV_NAMES, _VOX_RM_OPTS), # pos nargs: one or more 453 | ("vox rm venv1 venv2", _VENV_NAMES, _VOX_RM_OPTS), # pos nargs: two or more 454 | ], 455 | ) 456 | def test_vox_commands(self, args, positionals, opts, check, venvs): 457 | check(args, positionals, opts) 458 | 459 | @pytest.mark.parametrize( 460 | "args", 461 | [ 462 | "vox new --activate --interpreter", # option after option 463 | "vox new --interpreter", # "option: first 464 | "vox new --activate env1 --interpreter", # option after pos 465 | "vox new env1 --interpreter", # "option: at end" 466 | "vox new env1 --interpreter=", # "option: at end with 467 | ], 468 | ) 469 | def test_interpreter(self, check, args, patched_cmd_cache): 470 | check(args, _PY_BINS) 471 | -------------------------------------------------------------------------------- /tests/test_xontrib.py: -------------------------------------------------------------------------------- 1 | def test_it_loads(load_xontrib): 2 | load_xontrib("vox") 3 | -------------------------------------------------------------------------------- /xontrib/autovox.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manages automatic activation of virtual environments. 3 | 4 | This coordinates multiple automatic vox policies and deals with some of the 5 | mechanics of venv searching and chdir handling. 6 | 7 | This provides no interface for end users. 8 | 9 | Developers should look at XSH.builtins.events.autovox_policy 10 | """ 11 | import itertools 12 | import warnings 13 | from pathlib import Path 14 | 15 | from xonsh.built_ins import XSH, XonshSession 16 | 17 | import xontrib.voxapi as voxapi 18 | 19 | __all__ = () 20 | 21 | 22 | def autovox_policy(path: "Path") -> "str|Path|None": 23 | """ 24 | Register a policy with autovox. 25 | 26 | A policy is a function that takes a Path and returns the venv associated with it, 27 | if any. 28 | 29 | NOTE: The policy should only return a venv for this path exactly, not for 30 | parent paths. Parent walking is handled by autovox so that all policies can 31 | be queried at each level. 32 | """ 33 | 34 | 35 | class MultipleVenvsWarning(RuntimeWarning): 36 | pass 37 | 38 | 39 | def get_venv(vox, dirpath): 40 | # Search up the directory tree until a venv is found, or none 41 | for path in itertools.chain((dirpath,), dirpath.parents): 42 | venvs = [ 43 | vox[p] 44 | for p in XSH.builtins.events.autovox_policy.fire(path=path) 45 | if p is not None and p in vox # Filter out venvs that don't exist 46 | ] 47 | if len(venvs) == 0: 48 | continue 49 | else: 50 | if len(venvs) > 1: 51 | warnings.warn( 52 | MultipleVenvsWarning( 53 | "Found {numvenvs} venvs for {path}; using the first".format( 54 | numvenvs=len(venvs), path=path 55 | ) 56 | ) 57 | ) 58 | return venvs[0] 59 | 60 | 61 | def check_for_new_venv(curdir, olddir): 62 | vox = voxapi.Vox() 63 | if olddir is ... or olddir is None: 64 | try: 65 | oldve = vox[...] 66 | except KeyError: 67 | oldve = None 68 | else: 69 | oldve = get_venv(vox, olddir) 70 | newve = get_venv(vox, curdir) 71 | 72 | if oldve != newve: 73 | if newve is None: 74 | vox.deactivate() 75 | else: 76 | vox.activate(newve.env) 77 | 78 | 79 | # Core mechanism: Check for venv when the current directory changes 80 | 81 | 82 | def cd_handler(newdir, olddir, **_): 83 | check_for_new_venv(Path(newdir), Path(olddir)) 84 | 85 | 86 | # Recalculate when venvs are created or destroyed 87 | 88 | 89 | def create_handler(**_): 90 | check_for_new_venv(Path.cwd(), ...) 91 | 92 | 93 | def destroy_handler(**_): 94 | check_for_new_venv(Path.cwd(), ...) 95 | 96 | 97 | # Initial activation before first prompt 98 | 99 | 100 | def load_handler(**_): 101 | check_for_new_venv(Path.cwd(), None) 102 | 103 | 104 | def _load_xontrib_(xsh: XonshSession, **_): 105 | xsh.builtins.events.register(autovox_policy) 106 | xsh.builtins.events.on_chdir(cd_handler) 107 | xsh.builtins.events.vox_on_create(create_handler) 108 | xsh.builtins.events.vox_on_destroy(destroy_handler) 109 | xsh.builtins.events.on_post_init(load_handler) 110 | -------------------------------------------------------------------------------- /xontrib/vox.py: -------------------------------------------------------------------------------- 1 | """Python virtual environment manager for xonsh.""" 2 | import os.path 3 | import subprocess 4 | import tempfile 5 | import typing as tp 6 | from pathlib import Path 7 | 8 | import xonsh.cli_utils as xcli 9 | from xonsh.built_ins import XSH, XonshSession 10 | from xonsh.dirstack import pushd_fn 11 | from xonsh.platform import ON_WINDOWS 12 | from xonsh.tools import XonshError 13 | 14 | import xontrib.voxapi as voxapi 15 | 16 | __all__ = () 17 | 18 | 19 | def venv_names_completer(command, alias: "VoxHandler", **_): 20 | envs = alias.vox.keys() 21 | from xonsh.completers.path import complete_dir 22 | 23 | yield from envs 24 | 25 | paths, _ = complete_dir(command) 26 | yield from paths 27 | 28 | 29 | def py_interpreter_path_completer(xsh, **_): 30 | for _, (path, is_alias) in xsh.commands_cache.all_commands.items(): 31 | if not is_alias and ("/python" in path or "/pypy" in path): 32 | yield path 33 | 34 | 35 | _venv_option = xcli.Annotated[ 36 | tp.Optional[str], 37 | xcli.Arg(metavar="ENV", nargs="?", completer=venv_names_completer), 38 | ] 39 | 40 | 41 | class VoxHandler(xcli.ArgParserAlias): 42 | """Vox is a virtual environment manager for xonsh.""" 43 | 44 | def build(self): 45 | """lazily called during dispatch""" 46 | self.vox = voxapi.Vox() 47 | parser = self.create_parser(prog="vox") 48 | 49 | parser.add_command(self.new, aliases=["create"]) 50 | parser.add_command(self.activate, aliases=["workon", "enter"]) 51 | parser.add_command(self.deactivate, aliases=["exit"]) 52 | parser.add_command(self.list, aliases=["ls"]) 53 | parser.add_command(self.remove, aliases=["rm", "delete", "del"]) 54 | parser.add_command(self.info) 55 | parser.add_command(self.runin) 56 | parser.add_command(self.runin_all) 57 | parser.add_command(self.toggle_ssp) 58 | parser.add_command(self.wipe) 59 | parser.add_command(self.project_set) 60 | parser.add_command(self.project_get) 61 | parser.add_command(self.upgrade) 62 | 63 | return parser 64 | 65 | def hook_pre_add_argument(self, param: str, func, flags, kwargs): 66 | if func.__name__ in {"new", "upgrade"}: 67 | if ON_WINDOWS and param == "symlinks": 68 | # copies by default on windows 69 | kwargs["default"] = False 70 | kwargs["action"] = "store_true" 71 | kwargs["help"] = "Try to use symlinks rather than copies" 72 | flags = ["--symlinks"] 73 | return flags, kwargs 74 | 75 | def hook_post_add_argument(self, action, param: str, **_): 76 | if param == "interpreter": 77 | action.completer = py_interpreter_path_completer 78 | 79 | def new( 80 | self, 81 | name: xcli.Annotated[str, xcli.Arg(metavar="ENV")], 82 | interpreter: "str|None" = None, 83 | system_site_packages=False, 84 | symlinks=True, 85 | without_pip=False, 86 | activate=False, 87 | temporary=False, 88 | packages: xcli.Annotated[tp.Sequence[str], xcli.Arg(nargs="*")] = (), 89 | requirements: xcli.Annotated[tp.Sequence[str], xcli.Arg(action="append")] = (), 90 | link_project_dir=False, 91 | prompt: "str|None" = None, 92 | ): 93 | """Create a virtual environment in $VIRTUALENV_HOME with python3's ``venv``. 94 | 95 | Parameters 96 | ---------- 97 | name : str 98 | Virtual environment name 99 | interpreter: -p, --interpreter 100 | Python interpreter used to create the virtual environment. 101 | Can be configured via the $VOX_DEFAULT_INTERPRETER environment variable. 102 | system_site_packages : --system-site-packages, --ssp 103 | If True, the system (global) site-packages dir is available to 104 | created environments. 105 | symlinks : --copies 106 | Try to use copies rather than symlinks. 107 | without_pip : --without-pip, --wp 108 | Skips installing or upgrading pip in the virtual environment 109 | activate : -a, --activate 110 | Activate the newly created virtual environment. 111 | temporary: -t, --temp 112 | Create the virtualenv under a temporary directory. 113 | packages: -i, --install 114 | Install one or more packages (by repeating the option) after the environment is created using pip 115 | requirements: -r, --requirements 116 | The argument value is passed to ``pip -r`` to be installed. 117 | link_project_dir: -l, --link, --link-project 118 | Associate the current directory with the new environment. 119 | prompt: --prompt 120 | Provides an alternative prompt prefix for this environment. 121 | """ 122 | 123 | self.out("Creating environment...") 124 | 125 | if temporary: 126 | path = tempfile.mkdtemp(prefix=f"vox-env-{name}") 127 | name = os.path.join(path, name) 128 | 129 | self.vox.create( 130 | name, 131 | system_site_packages=system_site_packages, 132 | symlinks=symlinks, 133 | with_pip=(not without_pip), 134 | interpreter=interpreter, 135 | prompt=prompt, 136 | ) 137 | if link_project_dir: 138 | self.project_set(name) 139 | 140 | if packages: 141 | self.runin(name, ["pip", "install", *packages]) 142 | 143 | if requirements: 144 | 145 | def _generate_args(): 146 | for req in requirements: 147 | yield "-r" 148 | yield req 149 | 150 | self.runin(name, ["pip", "install"] + list(_generate_args())) 151 | 152 | if activate: 153 | self.activate(name) 154 | self.out(f"Environment {name!r} created and activated.\n") 155 | else: 156 | self.out( 157 | f'Environment {name!r} created. Activate it with "vox activate {name}".\n' 158 | ) 159 | 160 | def activate( 161 | self, 162 | name: _venv_option = None, 163 | no_cd=False, 164 | ): 165 | """Activate a virtual environment. 166 | 167 | Parameters 168 | ---------- 169 | name 170 | The environment to activate. 171 | ENV can be either a name from the venvs shown by ``vox list`` 172 | or the path to an arbitrary venv 173 | no_cd: -n, --no-cd 174 | Do not change current working directory even if a project path is associated with ENV. 175 | """ 176 | 177 | if name is None: 178 | return self.list() 179 | 180 | try: 181 | self.vox.activate(name) 182 | except KeyError: 183 | raise self.Error( 184 | f'This environment doesn\'t exist. Create it with "vox new {name}"', 185 | ) 186 | 187 | self.out(f'Activated "{name}".\n') 188 | if not no_cd: 189 | project_dir = self._get_project_dir(name) 190 | if project_dir: 191 | pushd_fn(project_dir) 192 | 193 | def deactivate(self, remove=False, force=False): 194 | """Deactivate the active virtual environment. 195 | 196 | Parameters 197 | ---------- 198 | remove: -r, --remove 199 | Remove the virtual environment after leaving it. 200 | force: -f, --force-removal 201 | Remove the virtual environment without prompt 202 | """ 203 | 204 | if self.vox.active() is None: 205 | raise self.Error( 206 | 'No environment currently active. Activate one with "vox activate".\n', 207 | ) 208 | env_name = self.vox.deactivate() 209 | if remove: 210 | self.vox.force_removals = force 211 | del self.vox[env_name] 212 | self.out(f'Environment "{env_name}" deactivated and removed.\n') 213 | else: 214 | self.out(f'Environment "{env_name}" deactivated.\n') 215 | 216 | def list(self): 217 | """List available virtual environments.""" 218 | 219 | try: 220 | envs = sorted(self.vox.keys()) 221 | except PermissionError: 222 | raise self.Error("No permissions on VIRTUALENV_HOME") 223 | 224 | if not envs: 225 | raise self.Error( 226 | 'No environments available. Create one with "vox new".\n', 227 | ) 228 | 229 | self.out("Available environments:") 230 | self.out("\n".join(envs)) 231 | 232 | def remove( 233 | self, 234 | names: xcli.Annotated[ 235 | tp.List[str], 236 | xcli.Arg(metavar="ENV", nargs="+", completer=venv_names_completer), 237 | ], 238 | force=False, 239 | ): 240 | """Remove virtual environments. 241 | 242 | Parameters 243 | ---------- 244 | names 245 | The environments to remove. ENV can be either a name from the venvs shown by vox 246 | list or the path to an arbitrary venv 247 | force : -f, --force 248 | Delete virtualenv without prompt 249 | """ 250 | self.vox.force_removals = force 251 | for name in names: 252 | try: 253 | del self.vox[name] 254 | except voxapi.EnvironmentInUse: 255 | raise self.Error( 256 | f'The "{name}" environment is currently active. ' 257 | 'In order to remove it, deactivate it first with "vox deactivate".\n', 258 | ) 259 | except KeyError: 260 | raise self.Error(f'"{name}" environment doesn\'t exist.\n') 261 | else: 262 | self.out(f'Environment "{name}" removed.') 263 | self.out() 264 | 265 | def _in_venv(self, env_dir: str, command: str, *args, **kwargs): 266 | env = {**XSH.env.detype(), "VIRTUAL_ENV": env_dir} 267 | 268 | bin_path = os.path.join(env_dir, self.vox.sub_dirs[0]) 269 | env["PATH"] = os.pathsep.join([bin_path, env["PATH"]]) 270 | 271 | for key in ("PYTHONHOME", "__PYVENV_LAUNCHER__"): 272 | env.pop(key, None) 273 | 274 | try: 275 | return subprocess.check_call( 276 | [command] + list(args), shell=bool(ON_WINDOWS), env=env, **kwargs 277 | ) 278 | # need to have shell=True on windows, otherwise the PYTHONPATH 279 | # won't inherit the PATH 280 | except OSError as e: 281 | if e.errno == 2: 282 | raise self.Error(f"Unable to find {command}") 283 | raise 284 | 285 | def runin( 286 | self, 287 | venv: xcli.Annotated[ 288 | str, 289 | xcli.Arg(completer=venv_names_completer), 290 | ], 291 | args: xcli.Annotated[tp.Sequence[str], xcli.Arg(nargs="...")], 292 | ): 293 | """Run the command in the given environment 294 | 295 | Parameters 296 | ---------- 297 | venv 298 | The environment to run the command for 299 | args 300 | The actual command to run 301 | 302 | Examples 303 | -------- 304 | vox runin venv1 black --check-only 305 | """ 306 | env_dir = self._get_env_dir(venv) 307 | if not args: 308 | raise self.Error("No command is passed") 309 | self._in_venv(env_dir, *args) 310 | 311 | def runin_all( 312 | self, 313 | args: xcli.Annotated[tp.Sequence[str], xcli.Arg(nargs="...")], 314 | ): 315 | """Run the command in all environments found under $VIRTUALENV_HOME 316 | 317 | Parameters 318 | ---------- 319 | args 320 | The actual command to run with arguments 321 | """ 322 | errors = False 323 | for env in self.vox: 324 | self.out("\n%s:" % env) 325 | try: 326 | self.runin(env, *args) 327 | except subprocess.CalledProcessError as e: 328 | errors = True 329 | self.err(e) 330 | self.parser.exit(errors) 331 | 332 | def _sitepackages_dir(self, venv_path: str): 333 | env_python = self.vox.get_binary_path("python", venv_path) 334 | if not os.path.exists(env_python): 335 | raise self.Error("no virtualenv active") 336 | 337 | return Path( 338 | subprocess.check_output( 339 | [ 340 | str(env_python), 341 | "-c", 342 | "import distutils; \ 343 | print(distutils.sysconfig.get_python_lib())", 344 | ] 345 | ).decode() 346 | ) 347 | 348 | def _get_env_dir(self, venv=None): 349 | venv = venv or ... 350 | try: 351 | env_dir = self.vox[venv].env 352 | except KeyError: 353 | # check whether the venv is a valid path to an environment 354 | if ( 355 | isinstance(venv, str) 356 | and os.path.exists(venv) 357 | and os.path.exists(self.vox.get_binary_path("python", venv)) 358 | ): 359 | return venv 360 | raise XonshError("No virtualenv is found") 361 | return env_dir 362 | 363 | def toggle_ssp(self): 364 | """Controls whether the active virtualenv will access the packages 365 | in the global Python site-packages directory.""" 366 | # https://virtualenv.pypa.io/en/legacy/userguide.html#the-system-site-packages-option 367 | env_dir = self._get_env_dir() # current 368 | site = self._sitepackages_dir(env_dir) 369 | ngsp_file = site.parent / "no-global-site-packages.txt" 370 | if ngsp_file.exists(): 371 | ngsp_file.unlink() 372 | self.out("Enabled global site-packages") 373 | else: 374 | with ngsp_file.open("w"): 375 | self.out("Disabled global site-packages") 376 | 377 | def project_set( 378 | self, 379 | venv: _venv_option = None, 380 | project_path=None, 381 | ): 382 | """Bind an existing virtualenv to an existing project. 383 | 384 | Parameters 385 | ---------- 386 | venv 387 | Name of the virtualenv, while the default being currently active venv. 388 | project_path 389 | Path to the project, while the default being current directory. 390 | """ 391 | env_dir = self._get_env_dir(venv) # current 392 | 393 | project = os.path.abspath(project_path or ".") 394 | if not os.path.exists(env_dir): 395 | raise self.Error(f"Environment '{env_dir}' doesn't exist.") 396 | if not os.path.isdir(project): 397 | raise self.Error(f"{project} does not exist") 398 | 399 | project_file = self._get_project_file() 400 | project_file.write_text(project) 401 | 402 | def _get_project_file( 403 | self, 404 | venv=None, 405 | ): 406 | env_dir = Path(self._get_env_dir(venv)) # current 407 | return env_dir / ".project" 408 | 409 | def _get_project_dir(self, venv=None): 410 | project_file = self._get_project_file(venv) 411 | if project_file.exists(): 412 | project_dir = project_file.read_text() 413 | if os.path.exists(project_dir): 414 | return project_dir 415 | 416 | def project_get(self, venv: _venv_option = None): 417 | """Return a virtualenv's project directory. 418 | 419 | Parameters 420 | ---------- 421 | venv 422 | Name of the virtualenv under $VIRTUALENV_HOME, while default being currently active venv. 423 | """ 424 | project_dir = self._get_project_dir(venv) 425 | if project_dir: 426 | self.out(project_dir) 427 | else: 428 | project_file = self._get_project_file(venv) 429 | raise self.Error( 430 | f"Corrupted or outdated: {project_file}\nDirectory: {project_dir} doesn't exist." 431 | ) 432 | 433 | def wipe(self, venv: _venv_option = None): 434 | """Remove all installed packages from the current (or supplied) env. 435 | 436 | Parameters 437 | ---------- 438 | venv 439 | name of the venv. Defaults to currently active venv 440 | """ 441 | env_dir = self._get_env_dir(venv) 442 | pip_bin = self.vox.get_binary_path("pip", env_dir) 443 | all_pkgs = set( 444 | subprocess.check_output([pip_bin, "freeze", "--local"]) 445 | .decode() 446 | .splitlines() 447 | ) 448 | pkgs = {p for p in all_pkgs if len(p.split("==")) == 2} 449 | ignored = sorted(all_pkgs - pkgs) 450 | to_remove = {p.split("==")[0] for p in pkgs} 451 | if to_remove: 452 | self.out("Ignoring:\n %s" % "\n ".join(ignored)) 453 | self.out("Uninstalling packages:\n %s" % "\n ".join(to_remove)) 454 | return subprocess.run([pip_bin, "uninstall", "-y", *to_remove]) 455 | else: 456 | self.out("Nothing to remove") 457 | 458 | def info(self, venv: _venv_option = None): 459 | """Prints the path for the supplied env 460 | 461 | Parameters 462 | ---------- 463 | venv 464 | name of the venv 465 | """ 466 | self.out(self.vox[venv or ...]) 467 | 468 | def upgrade( 469 | self, 470 | name: _venv_option = None, 471 | interpreter: "str|None" = None, 472 | symlinks=True, 473 | with_pip=False, 474 | ): 475 | """Upgrade the environment directory to use this version 476 | of Python, assuming Python has been upgraded in-place. 477 | 478 | WARNING: If a virtual environment was created with symlinks or without PIP, you must 479 | specify these options again on upgrade. 480 | 481 | Parameters 482 | ---------- 483 | name 484 | Name or the path to the virtual environment 485 | interpreter: -p, --interpreter 486 | Python interpreter used to create the virtual environment. 487 | Can be configured via the $VOX_DEFAULT_INTERPRETER environment variable. 488 | symlinks : --copies 489 | Try to use copies rather than symlinks. 490 | with_pip : --without-pip, --wp 491 | Skips installing or upgrading pip in the virtual environment 492 | """ 493 | venv = self.vox.upgrade( 494 | name or ..., symlinks=symlinks, with_pip=with_pip, interpreter=interpreter 495 | ) 496 | self.out(venv) 497 | 498 | 499 | def _load_xontrib_(xsh: XonshSession, **_): 500 | xsh.aliases["vox"] = VoxHandler(threadable=False) 501 | -------------------------------------------------------------------------------- /xontrib/voxapi.py: -------------------------------------------------------------------------------- 1 | """ 2 | API for Vox, the Python virtual environment manager for xonsh. 3 | 4 | Vox defines several events related to the life cycle of virtual environments: 5 | 6 | * ``vox_on_create(env: str) -> None`` 7 | * ``vox_on_activate(env: str, path: pathlib.Path) -> None`` 8 | * ``vox_on_deactivate(env: str, path: pathlib.Path) -> None`` 9 | * ``vox_on_delete(env: str) -> None`` 10 | """ 11 | import collections.abc 12 | import logging 13 | import os 14 | import shutil 15 | import subprocess as sp 16 | import sys 17 | import typing 18 | 19 | from xonsh.built_ins import XSH 20 | 21 | # This is because builtins aren't globally created during testing. 22 | # FIXME: Is there a better way? 23 | from xonsh.events import events 24 | from xonsh.platform import ON_POSIX, ON_WINDOWS 25 | 26 | events.doc( 27 | "vox_on_create", 28 | """ 29 | vox_on_create(env: str) -> None 30 | 31 | Fired after an environment is created. 32 | """, 33 | ) 34 | 35 | events.doc( 36 | "vox_on_activate", 37 | """ 38 | vox_on_activate(env: str, path: pathlib.Path) -> None 39 | 40 | Fired after an environment is activated. 41 | """, 42 | ) 43 | 44 | events.doc( 45 | "vox_on_deactivate", 46 | """ 47 | vox_on_deactivate(env: str, path: pathlib.Path) -> None 48 | 49 | Fired after an environment is deactivated. 50 | """, 51 | ) 52 | 53 | events.doc( 54 | "vox_on_delete", 55 | """ 56 | vox_on_delete(env: str) -> None 57 | 58 | Fired after an environment is deleted (through vox). 59 | """, 60 | ) 61 | 62 | 63 | class VirtualEnvironment(typing.NamedTuple): 64 | env: str 65 | bin: str 66 | lib: str 67 | inc: str 68 | 69 | 70 | def _subdir_names(): 71 | """ 72 | Gets the names of the special dirs in a venv. 73 | 74 | This is not necessarily exhaustive of all the directories that could be in a venv, and there 75 | may additional logic to get to useful places. 76 | """ 77 | if ON_WINDOWS: 78 | return "Scripts", "Lib", "Include" 79 | elif ON_POSIX: 80 | return "bin", "lib", "include" 81 | else: 82 | raise OSError("This OS is not supported.") 83 | 84 | 85 | def _mkvenv(env_dir): 86 | """ 87 | Constructs a VirtualEnvironment based on the given base path. 88 | 89 | This only cares about the platform. No filesystem calls are made. 90 | """ 91 | env_dir = os.path.abspath(env_dir) 92 | if ON_WINDOWS: 93 | binname = os.path.join(env_dir, "Scripts") 94 | incpath = os.path.join(env_dir, "Include") 95 | libpath = os.path.join(env_dir, "Lib", "site-packages") 96 | elif ON_POSIX: 97 | binname = os.path.join(env_dir, "bin") 98 | incpath = os.path.join(env_dir, "include") 99 | libpath = os.path.join( 100 | env_dir, "lib", "python%d.%d" % sys.version_info[:2], "site-packages" 101 | ) 102 | else: 103 | raise OSError("This OS is not supported.") 104 | 105 | return VirtualEnvironment(env_dir, binname, libpath, incpath) 106 | 107 | 108 | class EnvironmentInUse(Exception): 109 | """The given environment is currently activated, and the operation cannot be performed.""" 110 | 111 | 112 | class NoEnvironmentActive(Exception): 113 | """No environment is currently activated, and the operation cannot be performed.""" 114 | 115 | 116 | class Vox(collections.abc.Mapping): 117 | """API access to Vox and virtual environments, in a dict-like format. 118 | 119 | Makes use of the VirtualEnvironment namedtuple: 120 | 121 | 1. ``env``: The full path to the environment 122 | 2. ``bin``: The full path to the bin/Scripts directory of the environment 123 | """ 124 | 125 | def __init__(self, force_removals=False): 126 | if not XSH.env.get("VIRTUALENV_HOME"): 127 | home_path = os.path.expanduser("~") 128 | self.venvdir = os.path.join(home_path, ".virtualenvs") 129 | XSH.env["VIRTUALENV_HOME"] = self.venvdir 130 | else: 131 | self.venvdir = XSH.env["VIRTUALENV_HOME"] 132 | self.force_removals = force_removals 133 | self.sub_dirs = _subdir_names() 134 | 135 | def create( 136 | self, 137 | name, 138 | interpreter=None, 139 | system_site_packages=False, 140 | symlinks=False, 141 | with_pip=True, 142 | prompt=None, 143 | ): 144 | """Create a virtual environment in $VIRTUALENV_HOME with python3's ``venv``. 145 | 146 | Parameters 147 | ---------- 148 | name : str 149 | Virtual environment name 150 | interpreter: str 151 | Python interpreter used to create the virtual environment. 152 | Can be configured via the $VOX_DEFAULT_INTERPRETER environment variable. 153 | system_site_packages : bool 154 | If True, the system (global) site-packages dir is available to 155 | created environments. 156 | symlinks : bool 157 | If True, attempt to symlink rather than copy files into virtual 158 | environment. 159 | with_pip : bool 160 | If True, ensure pip is installed in the virtual environment. (Default is True) 161 | prompt: str 162 | Provides an alternative prompt prefix for this environment. 163 | """ 164 | if interpreter is None: 165 | interpreter = _get_vox_default_interpreter() 166 | print(f"Using Interpreter: {interpreter}") 167 | 168 | # NOTE: clear=True is the same as delete then create. 169 | # NOTE: upgrade=True is its own method 170 | if isinstance(name, os.PathLike): 171 | env_path = os.fspath(name) 172 | else: 173 | env_path = os.path.join(self.venvdir, name) 174 | if not self._check_reserved(env_path): 175 | raise ValueError( 176 | "venv can't contain reserved names ({})".format( 177 | ", ".join(self.sub_dirs) 178 | ) 179 | ) 180 | 181 | self._create( 182 | env_path, 183 | interpreter, 184 | system_site_packages, 185 | symlinks, 186 | with_pip, 187 | prompt=prompt, 188 | ) 189 | events.vox_on_create.fire(name=name) 190 | 191 | def upgrade(self, name, symlinks=False, with_pip=True, interpreter=None): 192 | """Create a virtual environment in $VIRTUALENV_HOME with python3's ``venv``. 193 | 194 | WARNING: If a virtual environment was created with symlinks or without PIP, you must 195 | specify these options again on upgrade. 196 | 197 | Parameters 198 | ---------- 199 | name : str 200 | Virtual environment name 201 | interpreter: str 202 | The Python interpreter used to create the virtualenv 203 | symlinks : bool 204 | If True, attempt to symlink rather than copy files into virtual 205 | environment. 206 | with_pip : bool 207 | If True, ensure pip is installed in the virtual environment. 208 | """ 209 | 210 | if interpreter is None: 211 | interpreter = _get_vox_default_interpreter() 212 | print(f"Using Interpreter: {interpreter}") 213 | 214 | # venv doesn't reload this, so we have to do it ourselves. 215 | # Is there a bug for this in Python? There should be. 216 | venv = self[name] 217 | cfgfile = os.path.join(venv.env, "pyvenv.cfg") 218 | cfgops = {} 219 | with open(cfgfile) as cfgfile: 220 | for line in cfgfile: 221 | line = line.strip() 222 | if "=" not in line: 223 | continue 224 | k, v = line.split("=", 1) 225 | cfgops[k.strip()] = v.strip() 226 | flags = { 227 | "system_site_packages": cfgops["include-system-site-packages"] == "true", 228 | "symlinks": symlinks, 229 | "with_pip": with_pip, 230 | } 231 | prompt = cfgops.get("prompt") 232 | if prompt: 233 | flags["prompt"] = prompt.lstrip("'\"").rstrip("'\"") 234 | # END things we shouldn't be doing. 235 | 236 | # Ok, do what we came here to do. 237 | self._create(venv.env, interpreter, upgrade=True, **flags) 238 | return venv 239 | 240 | @staticmethod 241 | def _create( 242 | env_path, 243 | interpreter, 244 | system_site_packages=False, 245 | symlinks=False, 246 | with_pip=True, 247 | upgrade=False, 248 | prompt=None, 249 | ): 250 | version_output = sp.check_output( 251 | [interpreter, "--version"], stderr=sp.STDOUT, text=True 252 | ) 253 | 254 | interpreter_major_version = int(version_output.split()[-1].split(".")[0]) 255 | module = "venv" if interpreter_major_version >= 3 else "virtualenv" 256 | system_site_packages = "--system-site-packages" if system_site_packages else "" 257 | symlinks = "--symlinks" if symlinks and interpreter_major_version >= 3 else "" 258 | with_pip = "" if with_pip else "--without-pip" 259 | upgrade = "--upgrade" if upgrade else "" 260 | 261 | cmd = [ 262 | interpreter, 263 | "-m", 264 | module, 265 | env_path, 266 | system_site_packages, 267 | symlinks, 268 | with_pip, 269 | upgrade, 270 | ] 271 | if prompt and module == "venv": 272 | cmd.extend(["--prompt", prompt]) 273 | 274 | cmd = [arg for arg in cmd if arg] # remove empty args 275 | logging.debug(cmd) 276 | 277 | sp.check_call(cmd) 278 | 279 | def _check_reserved(self, name): 280 | return ( 281 | os.path.basename(name) not in self.sub_dirs 282 | ) # FIXME: Check the middle components, too 283 | 284 | def __getitem__(self, name) -> "VirtualEnvironment": 285 | """Get information about a virtual environment. 286 | 287 | Parameters 288 | ---------- 289 | name : str or Ellipsis 290 | Virtual environment name or absolute path. If ... is given, return 291 | the current one (throws a KeyError if there isn't one). 292 | """ 293 | if name is ...: 294 | env = XSH.env 295 | env_paths = [env["VIRTUAL_ENV"]] 296 | elif isinstance(name, os.PathLike): 297 | env_paths = [os.fspath(name)] 298 | else: 299 | if not self._check_reserved(name): 300 | # Don't allow a venv that could be a venv special dir 301 | raise KeyError() 302 | 303 | env_paths = [] 304 | if os.path.isdir(name): 305 | env_paths += [name] 306 | env_paths += [os.path.join(self.venvdir, name)] 307 | 308 | for ep in env_paths: 309 | ve = _mkvenv(ep) 310 | 311 | # Actually check if this is an actual venv or just a organizational directory 312 | # eg, if 'spam/eggs' is a venv, reject 'spam' 313 | if not os.path.exists(ve.bin): 314 | continue 315 | return ve 316 | else: 317 | raise KeyError() 318 | 319 | def __contains__(self, name): 320 | # For some reason, MutableMapping seems to do this against iter, which is just silly. 321 | try: 322 | self[name] 323 | except KeyError: 324 | return False 325 | else: 326 | return True 327 | 328 | def get_binary_path(self, binary: str, *dirs: str): 329 | bin_, _, _ = self.sub_dirs 330 | python_exec = binary 331 | if ON_WINDOWS and not python_exec.endswith(".exe"): 332 | python_exec += ".exe" 333 | return os.path.join(*dirs, bin_, python_exec) 334 | 335 | def __iter__(self): 336 | """List available virtual environments found in $VIRTUALENV_HOME.""" 337 | for dirpath, dirnames, _ in os.walk(self.venvdir): 338 | python_exec = self.get_binary_path("python", dirpath) 339 | if os.access(python_exec, os.X_OK): 340 | yield dirpath[len(self.venvdir) + 1 :] # +1 is to remove the separator 341 | dirnames.clear() 342 | 343 | def __len__(self): 344 | """Counts known virtual environments, using the same rules as iter().""" 345 | line = 0 346 | for _ in self: 347 | line += 1 348 | return line 349 | 350 | def active(self): 351 | """Get the name of the active virtual environment. 352 | 353 | You can use this as a key to get further information. 354 | 355 | Returns None if no environment is active. 356 | """ 357 | env = XSH.env 358 | if "VIRTUAL_ENV" not in env: 359 | return 360 | env_path = env["VIRTUAL_ENV"] 361 | if env_path.startswith(self.venvdir): 362 | name = env_path[len(self.venvdir) :] 363 | if name[0] in "/\\": 364 | name = name[1:] 365 | return name 366 | else: 367 | return env_path 368 | 369 | def activate(self, name): 370 | """ 371 | Activate a virtual environment. 372 | 373 | Parameters 374 | ---------- 375 | name : str 376 | Virtual environment name or absolute path. 377 | """ 378 | env = XSH.env 379 | ve = self[name] 380 | if "VIRTUAL_ENV" in env: 381 | self.deactivate() 382 | 383 | type(self).oldvars = {"PATH": list(env["PATH"])} 384 | env["PATH"].insert(0, ve.bin) 385 | env["VIRTUAL_ENV"] = ve.env 386 | if "PYTHONHOME" in env: 387 | type(self).oldvars["PYTHONHOME"] = env.pop("PYTHONHOME") 388 | 389 | events.vox_on_activate.fire(name=name, path=ve.env) 390 | 391 | def deactivate(self): 392 | """ 393 | Deactivate the active virtual environment. Returns its name. 394 | """ 395 | env = XSH.env 396 | if "VIRTUAL_ENV" not in env: 397 | raise NoEnvironmentActive("No environment currently active.") 398 | 399 | env_name = self.active() 400 | 401 | if hasattr(type(self), "oldvars"): 402 | for k, v in type(self).oldvars.items(): 403 | env[k] = v 404 | del type(self).oldvars 405 | 406 | del env["VIRTUAL_ENV"] 407 | 408 | events.vox_on_deactivate.fire(name=env_name, path=self[env_name].env) 409 | return env_name 410 | 411 | def __delitem__(self, name): 412 | """ 413 | Permanently deletes a virtual environment. 414 | 415 | Parameters 416 | ---------- 417 | name : str 418 | Virtual environment name or absolute path. 419 | """ 420 | env_path = self[name].env 421 | try: 422 | if self[...].env == env_path: 423 | raise EnvironmentInUse( 424 | 'The "%s" environment is currently active.' % name 425 | ) 426 | except KeyError: 427 | # No current venv, ... fails 428 | pass 429 | 430 | env_path = os.path.abspath(env_path) 431 | if not self.force_removals: 432 | print(f"The directory {env_path}") 433 | print("and all of its content will be deleted.") 434 | answer = input("Do you want to continue? [Y/n]") 435 | if "n" in answer: 436 | return 437 | 438 | shutil.rmtree(env_path) 439 | 440 | events.vox_on_delete.fire(name=name) 441 | 442 | 443 | def _get_vox_default_interpreter(): 444 | """Return the interpreter set by the $VOX_DEFAULT_INTERPRETER if set else sys.executable""" 445 | default = "python3" 446 | if default in XSH.commands_cache: 447 | default = XSH.commands_cache.locate_binary(default) 448 | else: 449 | default = sys.executable 450 | return XSH.env.get("VOX_DEFAULT_INTERPRETER", default) 451 | --------------------------------------------------------------------------------