├── .coveragerc ├── .dockerignore ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── aactivator.py ├── ci └── docker ├── debian ├── .gitignore ├── changelog ├── compat ├── control ├── copyright ├── rules └── source │ └── format ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── testing.py ├── tests ├── __init__.py ├── conftest.py ├── integration_test.py └── unit_test.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | parallel = True 4 | source = 5 | . 6 | omit = 7 | .tox/* 8 | /usr/* 9 | setup.py 10 | 11 | [report] 12 | exclude_lines = 13 | # Have to re-enable the standard pragma 14 | \#\s*pragma: no cover 15 | 16 | # Don't complain if tests don't hit defensive assertion code: 17 | ^\s*raise AssertionError\b 18 | ^\s*raise NotImplementedError\b 19 | ^\s*return NotImplemented\b 20 | ^\s*raise$ 21 | 22 | # Don't complain if non-runnable code isn't run: 23 | ^if __name__ == ['"]__main__['"]:$ 24 | 25 | [html] 26 | directory = coverage-html 27 | 28 | # vim:ft=dosini 29 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | debian/aactivator 2 | 3 | .git 4 | .tox 5 | *.swp 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: build 3 | on: [push, pull_request] 4 | jobs: 5 | make: 6 | runs-on: ubuntu-22.04 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | make_target: 11 | - test 12 | - itest_focal 13 | - itest_jammy 14 | - itest_noble 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.11 20 | - run: pip install tox 21 | - run: sudo apt-get install -y --no-install-recommends zsh 22 | - run: make ${{ matrix.make_target }} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /aactivator-build-deps_* 2 | /build 3 | /dist 4 | /.cache 5 | /.coverage 6 | /.coverage.* 7 | /.tox 8 | 9 | *.deb 10 | *.egg-info 11 | *.py[co] 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.1.0 4 | hooks: 5 | - id: check-docstring-first 6 | - id: check-json 7 | - id: check-yaml 8 | - id: debug-statements 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | - repo: https://github.com/pycqa/flake8 12 | rev: 3.7.1 13 | hooks: 14 | - id: flake8 15 | - repo: https://github.com/pre-commit/mirrors-autopep8 16 | rev: v1.4.3 17 | hooks: 18 | - id: autopep8 19 | - repo: https://github.com/asottile/reorder_python_imports 20 | rev: v1.3.5 21 | hooks: 22 | - id: reorder-python-imports 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to aactivator 2 | ======== 3 | 4 | `aactivator` is primarily developed by [Yelp](https://yelp.github.io/), but 5 | contributions are welcome from everyone! 6 | 7 | Code is reviewed using GitHub pull requests. To make a contribution, you should: 8 | 9 | 1. Fork the GitHub repository 10 | 2. Push code to a branch on your fork 11 | 3. Create a pull request and wait for it to be reviewed 12 | 13 | We aim to have all aactivator behavior covered by tests. If you make a change in 14 | behavior, please add a test to ensure it doesn't regress. We're also happy to 15 | help with suggestions on testing! 16 | 17 | 18 | ## Releasing new versions 19 | 20 | `aactivator` uses [semantic versioning](http://semver.org/). If you're making a 21 | contribution, please don't bump the version number yourself—we'll take care of 22 | that after merging! 23 | 24 | The process to release a new version is: 25 | 26 | 1. Update the version in `aactivator.py` 27 | 2. Update the Debian changelog with `dch -v {new version}`. 28 | 3. Commit the changes and tag the commit like `v1.0.0`. 29 | 4. `git push --tags origin master` 30 | 5. Run `python setup.py bdist_wheel` 31 | 6. Run `twine upload --skip-existing dist/*.whl` to upload the new version to 32 | PyPI 33 | 7. Run `make builddeb-docker` 34 | 8. Upload the resulting Debian package to a new [GitHub 35 | release](https://github.com/Yelp/aactivator/releases) 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:jammy 2 | 3 | RUN apt-get update \ 4 | && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 5 | build-essential \ 6 | devscripts \ 7 | dumb-init \ 8 | equivs \ 9 | lintian \ 10 | && apt-get clean 11 | 12 | # debuild will fail when running directly against /mnt, so we copy the files we need 13 | RUN mkdir /build 14 | COPY debian /build/debian 15 | COPY Makefile aactivator.py /build/ 16 | WORKDIR /build 17 | 18 | CMD [ \ 19 | "dumb-init", \ 20 | "sh", "-euxc", \ 21 | "mk-build-deps -ir --tool 'apt-get --no-install-recommends -y' debian/control && make builddeb && cp ./dist/* /mnt/dist" \ 22 | ] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Yelp, Inc. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include VERSION 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=bash 2 | 3 | .PHONY: test 4 | test: 5 | tox 6 | 7 | .PHONY: clean 8 | clean: 9 | rm -rf .tox 10 | 11 | .PHONY: builddeb 12 | builddeb: 13 | mkdir -p dist 14 | debuild -us -uc -b 15 | mv ../aactivator_*.deb dist/ 16 | 17 | 18 | # itest / docker build 19 | DOCKER_BUILDER := aactivator-builder-$(USER) 20 | DOCKER_RUN_TEST := docker run -e DEBIAN_FRONTEND=noninteractive -e PIP_INDEX_URL -v $(PWD):/mnt:ro 21 | 22 | .PHONY: docker-builder-image 23 | docker-builder-image: 24 | docker build -t $(DOCKER_BUILDER) . 25 | 26 | .PHONY: builddeb-docker 27 | builddeb-docker: docker-builder-image 28 | mkdir -p dist 29 | docker run -v $(PWD):/mnt $(DOCKER_BUILDER) 30 | 31 | ITEST_TARGETS = itest_focal itest_jammy 32 | 33 | .PHONY: itest 34 | itest: $(ITEST_TARGETS) 35 | 36 | .PHONY: itest_% 37 | itest_%: builddeb-docker 38 | $(DOCKER_RUN_TEST) ubuntu:$* /mnt/ci/docker 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | aactivator 2 | ======== 3 | 4 | [![PyPI version](https://badge.fury.io/py/aactivator.svg)](https://pypi.python.org/pypi/aactivator) 5 | ![Build Status](https://github.com/Yelp/aactivator/workflows/build/badge.svg?branch=master) 6 | 7 | `aactivator` is a simple tool that automatically sources ("activates") and 8 | unsources a project's environment when entering and exiting it. 9 | 10 | Key features of aactivator include: 11 | 12 | * Prompting before sourcing previously-unseen directories. 13 | * Refusing to source files that can be modified via others. 14 | * First-class support for both `bash` and `zsh`. 15 | * Well-tested, with integration tests applied to both supported shells. 16 | 17 | aactivator supports Python 2.7, 3.4+; it has no dependencies besides 18 | the standard library. 19 | 20 | 21 | ## The aactivator interface 22 | 23 | aactivator provides a simple interface for projects, via two files at the root 24 | of the project: 25 | 26 | * `.activate.sh`, which is sourced by the shell on enter. 27 | 28 | If working with Python virtualenvs, it usually makes the most sense to 29 | symlink `.activate.sh` to the `bin/activate` file inside your virtualenv. 30 | For example, `ln -s venv/bin/activate .activate.sh`. This symlink can be 31 | checked directly into git (just make sure to use a relative symlink, like in 32 | the command before). 33 | 34 | * `.deactivate.sh`, which is sourced by the shell on exit. 35 | 36 | For Python projects, this is typically just a one-line file that contains 37 | `deactivate`, though it can be modified to suit your particular project. 38 | 39 | Note that neither of these files need to be executable or contain a shebang. 40 | This is because they are *sourced* (run inside your current shell) and not 41 | *executed*. 42 | 43 | 44 | ## Installing into your shell 45 | 46 | We recommend adding `aactivator` to your shell's config. It will stay out of 47 | your way during regular usage, and you'll only ever notice it doing its job 48 | when you `cd` into a project directory that supports aactivator. 49 | 50 | You first need to install the `aactivator` binary somewhere on your system. You 51 | have a few options: 52 | 53 | 1. Just copy the [`aactivator.py` script][aactivator.py-master] somewhere on 54 | your system and make it executable (`chmod +x aactivator.py`). It has no 55 | dependencies besides the Python standard library. 56 | 57 | 2. Install it via pip (`pip install aactivator`). You can install system-wide, 58 | to your home directory, or into a virtualenv (your preference). 59 | 60 | 3. Install the Debian package. This is the best option for system-wide 61 | automated installations, and gives you other niceties like a man-page. 62 | You can find pre-built Debian packages under the [Releases][releases] GitHub 63 | tab. 64 | 65 | Once you have `aactivator` installed, you need to enable it on login. To do 66 | that, just add this line to the `.bashrc` (or `.zshrc` for zsh) file in your 67 | home directory: 68 | 69 | eval "$(aactivator init)" 70 | 71 | (You may need to prefix `aactivator` with the full path to the binary if you 72 | didn't install it somewhere on your `$PATH`). 73 | 74 | 75 | ## Motivation 76 | 77 | Automatically sourcing virtualenvs is a huge boon to large projects. It means 78 | that you can directly execute tools like `pytest`, and also that the project 79 | can register command-line tools (via setuptools' `console_scripts` entrypoint) 80 | for use by contributors. 81 | 82 | 83 | ## Security considerations 84 | 85 | We tried pretty hard to make this not a giant arbitrary-code-execution vector. 86 | There are two main protections: 87 | 88 | * `aactivator` asks before sourcing previously-unseen directories. You can 89 | choose between not sourcing once, never sourcing, or sourcing. 90 | 91 | You shouldn't choose to source projects whose code you don't trust. However, 92 | it's worth keeping in mind that the same consideration exists with running 93 | tests, building the virtualenv, or running any of that project's code. 94 | Sourcing the virtualenv is just as dangerous as any of these. 95 | 96 | * `aactivator` refuses to source environment files which can be modified by 97 | others. It does this by recursing upwards from the current directory until 98 | hitting a filesystem boundary, and checking that the file (and all of its 99 | parents) can be modified by only you and `root`. 100 | 101 | 102 | ## Alternatives to aactivator 103 | 104 | Some alternatives to `aactivator` already exist. For example: 105 | 106 | * [kennethreitz's autoenv][autoenv] 107 | * [codysoyland's virtualenv-auto-activate][codysoyland] 108 | * [yourlabs's shell function][yourlabs] 109 | * [direnv] 110 | 111 | These alternatives all have at least one of the following problems (compared to 112 | aactivator): 113 | 114 | * Don't ask (or remember) permission before sourcing directories 115 | * Don't deactivate when leaving project directories 116 | * Work by overriding the `cd` builtin (which means things like `popd` or other 117 | methods of changing directories don't work) 118 | * Lack support for `zsh` 119 | * Don't perform important security checks (see "Security" above) 120 | 121 | 122 | [aactivator.py-master]: https://github.com/Yelp/aactivator/blob/master/aactivator.py 123 | [autoenv]: https://github.com/kennethreitz/autoenv 124 | [codysoyland]: https://gist.github.com/codysoyland/2198913 125 | [releases]: https://github.com/Yelp/aactivator/releases 126 | [yourlabs]: http://blog.yourlabs.org/post/21015702927/automatic-virtualenv-activation 127 | [direnv]: http://github.com/direnv/direnv/ 128 | -------------------------------------------------------------------------------- /aactivator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | """\ 4 | Usage: eval "$(aactivator init)" 5 | 6 | aactivator is a script for automatically sourcing environments in an interactive shell. 7 | The interface for using this is two files: 8 | 9 | - .activate.sh: when sourced, this file activates your environment 10 | - .deactivate.sh: when sourced, this file deactivates your environment 11 | 12 | A typical setup in a python project: 13 | 14 | $ ln -vs venv/bin/activate .activate.sh 15 | $ echo deactivate > .deactivate.sh 16 | 17 | If an environment is already active it will not be re-activated. 18 | If a different project is activated, the previous project will be deactivated beforehand. 19 | 20 | aactivator will ask before automatically sourcing environments, and optionally 21 | remember your answer. You can later adjust your per-project preferences in the 22 | ~/.cache/aactivator/ directory. 23 | 24 | see also: https://github.com/Yelp/aactivator 25 | """ 26 | from __future__ import absolute_import 27 | from __future__ import print_function 28 | from __future__ import unicode_literals 29 | 30 | import io 31 | import os.path 32 | import stat 33 | import sys 34 | from os.path import relpath 35 | from shlex import quote 36 | 37 | 38 | ENVIRONMENT_VARIABLE = 'AACTIVATOR_ACTIVE' 39 | ACTIVATE = '.activate.sh' 40 | DEACTIVATE = '.deactivate.sh' 41 | 42 | __version__ = '2.0.0' 43 | 44 | 45 | def init(arg0): 46 | arg0 = os.path.realpath(arg0) 47 | cmd = 'if [ -x {exe} ]; then eval "`{exe}`"; fi'.format(exe=arg0) 48 | return '''\ 49 | export AACTIVATOR_VERSION={version} 50 | alias aactivator={arg0} 51 | unset {varname} 52 | if [ "$ZSH_VERSION" ]; then 53 | precmd_aactivator() {{ {cmd}; }} 54 | if ! [ "${{precmd_functions[(r)precmd_aactivator]}}" ]; then 55 | precmd_functions=(precmd_aactivator $precmd_functions) 56 | fi 57 | else 58 | if ! ( echo "$PROMPT_COMMAND" | grep -Fq '{cmd}' ); then 59 | PROMPT_COMMAND='{cmd}; '"$PROMPT_COMMAND" 60 | fi 61 | fi'''.format(version=__version__, arg0=arg0, cmd=cmd, varname=ENVIRONMENT_VARIABLE) 62 | 63 | 64 | def get_filesystem_id(path): 65 | try: 66 | return os.stat(path).st_dev 67 | except OSError as error: 68 | if error.errno == 2: # no such file 69 | return None 70 | else: 71 | raise 72 | 73 | 74 | def insecure_inode(path): 75 | """This particular inode can be altered by someone other than the owner""" 76 | pathstat = os.stat(path).st_mode 77 | # Directories with a sticky bit are always acceptable. 78 | if os.path.isdir(path) and pathstat & stat.S_ISVTX: 79 | return False 80 | # The path is writable by someone who is not us. 81 | elif pathstat & (stat.S_IWGRP | stat.S_IWOTH): 82 | return True 83 | else: 84 | return False 85 | 86 | 87 | def first(iterable, predicate): 88 | for x in iterable: 89 | if predicate(x): 90 | return x 91 | 92 | 93 | def insecure(path): 94 | """Find an insecure path, at or above this one""" 95 | return first(search_parent_paths(path), insecure_inode) 96 | 97 | 98 | def search_parent_paths(path): 99 | path = os.path.abspath(path) 100 | original_fs_id = fs_id = get_filesystem_id(path) 101 | previous_path = None 102 | while original_fs_id == fs_id and path != previous_path: 103 | yield path 104 | previous_path = path 105 | path = os.path.dirname(path) 106 | fs_id = get_filesystem_id(path) 107 | 108 | 109 | def error_command(message): 110 | return 'echo %s >&2' % quote('aactivator: ' + message) 111 | 112 | 113 | def mkdirp(path): 114 | try: 115 | os.makedirs(path) 116 | except OSError: 117 | if os.path.isdir(path): 118 | return 119 | else: 120 | raise 121 | 122 | 123 | def _get_lines_if_there(path): 124 | if os.path.exists(path): 125 | return io.open(path).read().splitlines() 126 | else: 127 | return [] 128 | 129 | 130 | class ConfigFile(object): 131 | 132 | def __init__(self, directory, name): 133 | self.path = os.path.join(directory, name) 134 | self.lines = frozenset(_get_lines_if_there(self.path)) 135 | 136 | def write(self, mode, value): 137 | mkdirp(os.path.dirname(self.path)) 138 | with io.open(self.path, mode) as file_obj: 139 | file_obj.write(value) 140 | 141 | def append(self, value): 142 | self.write('a', value + '\n') 143 | 144 | 145 | def path_is_under(path, under): 146 | relpath = os.path.relpath(path, under).split('/') 147 | return not relpath[:1] == ['..'] 148 | 149 | 150 | def user_cache_dir(env): 151 | # stolen from pip.utils.appdirs.user_cache_dir 152 | # expanduser doesn't take an env argument -.- 153 | from os.path import expanduser 154 | orig, os.environ = os.environ, env 155 | try: 156 | return expanduser(env.get('XDG_CACHE_HOME', '~/.cache')) 157 | finally: 158 | os.environ = orig 159 | 160 | 161 | class ActivateConfig(object): 162 | 163 | def __init__(self, env, get_input): 164 | self.env = env 165 | self.get_input = get_input 166 | self.path = os.path.join(user_cache_dir(self.env), 'aactivator') 167 | self.allowed = ConfigFile(self.path, 'allowed') 168 | self.not_now = ConfigFile(self.path, 'not-now') 169 | self.disallowed = ConfigFile(self.path, 'disallowed') 170 | 171 | def refresh_not_now(self, pwd): 172 | result = [] 173 | for path in self.not_now.lines: 174 | dirname = os.path.dirname(path) 175 | if path_is_under(pwd, dirname): 176 | result.append(path) 177 | self.not_now.write('w', '\n'.join(result)) 178 | 179 | def _prompt_user(self, path): 180 | print( 181 | 'aactivator will source {0} and {1} at {2}.'.format( 182 | ACTIVATE, DEACTIVATE, path, 183 | ), 184 | file=sys.stderr, 185 | ) 186 | while True: 187 | print('Acceptable? (y)es (n)o (N)ever: ', file=sys.stderr, end='') 188 | sys.stderr.flush() 189 | try: 190 | response = self.get_input() 191 | # Allow ^D to be "no" 192 | except EOFError: 193 | response = 'n' 194 | 195 | if response.startswith('N'): 196 | self.disallowed.append(path) 197 | print( 198 | 'aactivator will remember this: ' 199 | '~/.cache/aactivator/disallowed', 200 | file=sys.stderr, 201 | ) 202 | return False 203 | 204 | response = response.lower() 205 | if response.startswith('n'): 206 | self.not_now.append(path) 207 | return False 208 | elif response.startswith('y'): 209 | self.allowed.append(path) 210 | print( 211 | 'aactivator will remember this: ' 212 | '~/.cache/aactivator/allowed', 213 | file=sys.stderr, 214 | ) 215 | return True 216 | else: 217 | print("I didn't understand your response.", file=sys.stderr) 218 | print(file=sys.stderr) 219 | 220 | def find_allowed(self, path): 221 | self.refresh_not_now(path) 222 | return first(search_parent_paths(path), self.is_allowed) 223 | 224 | def is_allowed(self, path, _getuid=os.getuid): 225 | activate = os.path.join(path, ACTIVATE) 226 | if not os.path.exists(activate): 227 | return False 228 | elif os.stat(activate).st_uid != _getuid(): 229 | # If we do not own this path, short circuit on activating 230 | return False 231 | elif path in self.disallowed.lines or path in self.not_now.lines: 232 | return False 233 | elif path in self.allowed.lines: 234 | return True 235 | else: 236 | return self._prompt_user(path) 237 | 238 | 239 | def security_check(path): 240 | if not os.path.exists(path): 241 | return 'aactivator: File does not exist: ' + path 242 | insecure_path = insecure(path) 243 | if insecure_path is not None: 244 | return ( 245 | 'aactivator: Cowardly refusing to source {0} because writeable by others: {1}' 246 | .format(relpath(path), relpath(insecure_path)) 247 | ) 248 | 249 | 250 | def command_for_path(cmd, path, pwd): 251 | if path == pwd: 252 | return cmd 253 | else: 254 | return ' &&\n'.join(( 255 | 'OLDPWD_bak="$OLDPWD"', 256 | 'cd ' + quote(path), 257 | cmd, 258 | 'cd "$OLDPWD_bak"', 259 | 'cd ' + quote(pwd), 260 | 'unset OLDPWD_bak', 261 | )) 262 | 263 | 264 | def aactivate(path, pwd): 265 | return command_for_path( 266 | ' &&\n'.join(( 267 | 'aactivator security-check ' + ACTIVATE, 268 | 'source ./' + ACTIVATE, 269 | 'export %s=%s' % (ENVIRONMENT_VARIABLE, quote(path)), 270 | )), 271 | path, 272 | pwd, 273 | ) 274 | 275 | 276 | def deaactivate(path, pwd): 277 | unset = 'unset ' + ENVIRONMENT_VARIABLE 278 | deactivate_path = os.path.join(path, DEACTIVATE) 279 | 280 | if os.path.exists(deactivate_path): 281 | return command_for_path( 282 | ' &&\n'.join(( 283 | 'aactivator security-check ' + DEACTIVATE, 284 | 'source ./' + DEACTIVATE, 285 | )) + '\n' + unset, 286 | path, 287 | pwd, 288 | ) 289 | else: 290 | return ' &&\n'.join(( 291 | unset, 292 | error_command('Cannot deactivate. File missing: {0}'.format(deactivate_path)) 293 | )) 294 | 295 | 296 | def get_output(environ, pwd='.', get_input=sys.stdin.readline, arg0='/path/to/aactivator'): 297 | try: 298 | pwd = os.path.realpath(pwd) 299 | except OSError as error: 300 | if error.errno == 2: # no such file 301 | return '' 302 | else: 303 | raise 304 | config = ActivateConfig(environ, get_input) 305 | activate_path = config.find_allowed(pwd) 306 | result = [] 307 | 308 | if environ.get('AACTIVATOR_VERSION') == __version__: 309 | activated_env = environ.get(ENVIRONMENT_VARIABLE) 310 | else: 311 | result.append(init(arg0)) 312 | activated_env = None 313 | 314 | if activated_env != activate_path: # did we already activate the current environment? 315 | if activated_env: # deactivate it 316 | result.append(deaactivate(activated_env, pwd)) 317 | if activate_path: 318 | result.append(aactivate(activate_path, pwd)) 319 | return ' &&\n'.join(result) 320 | 321 | 322 | def aactivator(args, env): 323 | if len(args) == 1: 324 | return get_output(env, arg0=args[0]) 325 | elif len(args) == 2 and args[1] == 'init': 326 | return init(args[0]) 327 | elif len(args) == 3 and args[1] == 'security-check': 328 | exit(security_check(args[2])) 329 | else: 330 | return __doc__ + '\nVersion: ' + __version__ 331 | 332 | 333 | def main(): 334 | try: 335 | print(aactivator(tuple(sys.argv), os.environ.copy())) 336 | except KeyboardInterrupt: # pragma: no cover 337 | # Silence ^C 338 | pass 339 | 340 | 341 | if __name__ == '__main__': 342 | try: 343 | sys.exit(main()) 344 | except KeyboardInterrupt: 345 | # Silence ^C 346 | pass 347 | -------------------------------------------------------------------------------- /ci/docker: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | # This script gets run inside the itest Docker containers. 3 | set -o pipefail 4 | 5 | # We need to set standard /tmp permissions since it's a Docker volume. 6 | chmod 1777 /tmp 7 | 8 | apt-get update 9 | apt-get install -y --no-install-recommends \ 10 | bash \ 11 | build-essential \ 12 | ca-certificates \ 13 | curl \ 14 | gdebi-core \ 15 | python3 \ 16 | virtualenv \ 17 | zsh 18 | 19 | gdebi -n /mnt/dist/*.deb 20 | 21 | # pip & pytest can't deal with a read-only filesystem 22 | cp -r /mnt /tmp/test 23 | virtualenv -ppython3 /tmp/venv 24 | /tmp/venv/bin/pip install -r /tmp/test/requirements-dev.txt 25 | /tmp/venv/bin/pip install /tmp/test 26 | /tmp/venv/bin/pytest -vv /tmp/test 27 | -------------------------------------------------------------------------------- /debian/.gitignore: -------------------------------------------------------------------------------- 1 | /*.log 2 | /files 3 | /*.debhelper 4 | /*.substvars 5 | /aactivator 6 | /debhelper-build-stamp 7 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | aactivator (2.0.0) unstable; urgency=medium 2 | 3 | * Drop Python 2 support (thanks @kennydo!) 4 | * Replace import which is deprecated in python3.11 and removed in 5 | python3.13 (thanks @kennydo!) 6 | 7 | -- Chris Kuehl Tue, 21 Feb 2023 11:49:23 -0800 8 | 9 | aactivator (1.0.1) unstable; urgency=medium 10 | 11 | * Switch to Python 3 by default. 12 | * Correctly check parent directories during the security check. 13 | * Don't error if aactivator goes missing (e.g. is uninstalled while still 14 | the shell config). 15 | 16 | -- Chris Kuehl Fri, 25 Jan 2019 10:04:59 -0800 17 | 18 | aactivator (1.0.0) unstable; urgency=low 19 | 20 | * Sentimental bump to v1.0.0 21 | 22 | -- Chris Kuehl Mon, 11 Jul 2016 15:52:18 -0700 23 | 24 | aactivator (1.0.0~dev1) unstable; urgency=low 25 | 26 | * Initial release. 27 | 28 | -- Chris Kuehl Tue, 17 May 2016 11:35:47 -0700 29 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 11 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: aactivator 2 | Section: utils 3 | Priority: extra 4 | Maintainer: Chris Kuehl 5 | Build-Depends: debhelper (>= 9) 6 | Standards-Version: 3.9.6 7 | Vcs-Git: https://github.com/Yelp/aactivator.git 8 | Vcs-Browser: https://github.com/Yelp/aactivator 9 | Homepage: https://github.com/Yelp/aactivator 10 | 11 | Package: aactivator 12 | Architecture: all 13 | Depends: python3 14 | Description: automatically activate virtual environments 15 | aactivator automatically activates environments when entering and exiting 16 | directories. 17 | . 18 | By default, aactivator is not enabled. To opt-in to aactivator in your shell, 19 | add `eval "$(aactivator init)"` somewhere to your shell's rc files. 20 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: aactivator 3 | Source: https://github.com/Yelp/aactivator/ 4 | 5 | Files: * 6 | Copyright: 7 | 2016 Yelp, Inc. 8 | License: Expat 9 | 10 | License: Expat 11 | Permission is hereby granted, free of charge, to any person obtaining a 12 | copy of this software and associated documentation files (the "Software"), 13 | to deal in the Software without restriction, including without limitation 14 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 15 | and/or sell copies of the Software, and to permit persons to whom the 16 | Software is furnished to do so, subject to the following conditions: 17 | . 18 | The above copyright notice and this permission notice shall be included in 19 | all copies or substantial portions of the Software. 20 | . 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 24 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 26 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 27 | DEALINGS IN THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | %: 3 | dh $@ 4 | 5 | override_dh_auto_build: 6 | @true 7 | 8 | override_dh_auto_test: 9 | @true 10 | 11 | override_dh_install: 12 | mkdir -p debian/aactivator/usr/bin 13 | cp aactivator.py debian/aactivator/usr/bin/aactivator 14 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | coverage<5 # Remove constraint once off python 3.6.0 on xenial 2 | coverage-enable-subprocess 3 | ipdb 4 | pexpect 5 | pre-commit 6 | pytest 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = True 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | from aactivator import __version__ 4 | 5 | 6 | setup( 7 | name='aactivator', 8 | description=( 9 | 'Automatically activate Python virtualenvs (and other environments).' 10 | ), 11 | url='https://github.com/Yelp/aactivator', 12 | version=__version__, 13 | author='Yelp', 14 | platforms='linux', 15 | classifiers=[ 16 | 'License :: OSI Approved :: MIT License', 17 | 'Programming Language :: Python :: 3', 18 | 'Programming Language :: Python :: 3.7', 19 | 'Programming Language :: Python :: 3.8', 20 | 'Programming Language :: Python :: 3.9', 21 | 'Programming Language :: Python :: 3.10', 22 | 'Programming Language :: Python :: 3.11', 23 | ], 24 | python_requires='>=3.7', 25 | py_modules=['aactivator'], 26 | entry_points={ 27 | 'console_scripts': [ 28 | 'aactivator = aactivator:main', 29 | ], 30 | }, 31 | ) 32 | -------------------------------------------------------------------------------- /testing.py: -------------------------------------------------------------------------------- 1 | def make_venv_in_tempdir(tmpdir, name='venv'): 2 | venv = tmpdir.mkdir(name) 3 | venv.mkdir('child-dir') 4 | venv.join('banner').write('aactivating...\n') 5 | venv.join('.activate.sh').write('''\ 6 | cat banner 7 | alias echo='echo "(aliased)"' 8 | ''') 9 | venv.join('.deactivate.sh').write('echo deactivating...\nunalias echo\n') 10 | return venv 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/aactivator/576301dbd32d9043ebcee7349813400107ee34fc/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | from __future__ import unicode_literals 5 | 6 | import os 7 | 8 | import pytest 9 | 10 | 11 | @pytest.fixture(autouse=True) 12 | def cwd(): 13 | old_dir = os.getcwd() 14 | os.chdir('/') 15 | 16 | try: 17 | yield 18 | finally: 19 | os.chdir(old_dir) 20 | 21 | 22 | @pytest.fixture 23 | def venv_path(tmpdir): 24 | return tmpdir.join('venv') 25 | 26 | 27 | @pytest.fixture 28 | def activate(venv_path): 29 | return venv_path.join('.activate.sh') 30 | 31 | 32 | @pytest.fixture 33 | def deactivate(venv_path): 34 | return venv_path.join('.deactivate.sh') 35 | -------------------------------------------------------------------------------- /tests/integration_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | from __future__ import unicode_literals 5 | 6 | import functools 7 | import os.path 8 | import re 9 | import shutil 10 | import sys 11 | from shlex import quote 12 | 13 | import pexpect 14 | import pytest 15 | 16 | import aactivator 17 | from testing import make_venv_in_tempdir 18 | 19 | 20 | PS1 = 'TEST> ' 21 | INPUT = 'INPUT> ' 22 | 23 | 24 | def get_proc(shell, homedir): 25 | return pexpect.spawn( 26 | shell[0], list(shell[1:]), 27 | timeout=5, 28 | env={ 29 | 'BASH_SILENCE_DEPRECATION_WARNING': '1', # macOS 30 | 'COVERAGE_PROCESS_START': os.environ.get( 31 | 'COVERAGE_PROCESS_START', '', 32 | ), 33 | 'PS1': PS1, 34 | 'TOP': os.environ.get('TOP', ''), 35 | 'HOME': str(homedir), 36 | 'PATH': os.pathsep.join([os.path.dirname(sys.executable), os.defpath]), 37 | }, 38 | ) 39 | 40 | 41 | def expect_exact_better(proc, expected): 42 | """Uses raw strings like expect_exact, but starts looking from the start. 43 | """ 44 | # I'd put a $ on the end of this regex, but sometimes the buffer comes 45 | # to us too quickly for our assertions 46 | before = proc.before or b'' 47 | after = proc.after or b'' 48 | reg = '^' + re.escape(expected) 49 | reg = reg.replace('\n', '\r*\n') 50 | try: 51 | proc.expect(reg) 52 | except pexpect.TIMEOUT: # pragma: no cover 53 | message = ( 54 | 'Incorrect output.', 55 | '>>> Context:', 56 | before.decode('utf8') + after.decode('utf8'), 57 | '>>> Expected:', 58 | ' ' + 59 | expected.replace('\r', '').replace('\n', '\n '), 60 | '>>> Actual:', 61 | ' ' + 62 | proc.buffer.replace(b'\r', b'').replace(b'\n', b'\n ').decode('utf8'), 63 | ) 64 | message = '\n'.join(message) 65 | raise AssertionError(message) 66 | 67 | 68 | def run_cmd(proc, line, output='', ps1=PS1): 69 | if ps1: 70 | expect_exact_better(proc, ps1) 71 | proc.sendline(line) 72 | expect_exact_better(proc, line + '\n' + output) 73 | 74 | 75 | run_input = functools.partial(run_cmd, ps1='') 76 | 77 | 78 | def parse_tests(tests): 79 | cmds = [] 80 | cmd_fn = None 81 | cmd = None 82 | output = '' 83 | 84 | for line in tests.splitlines(): 85 | if line.startswith((PS1, INPUT)): 86 | if cmd_fn is not None: 87 | cmds.append((cmd_fn, cmd, output)) 88 | cmd = None 89 | cmd_fn = None 90 | output = '' 91 | elif INPUT in line: 92 | if cmd_fn is not None: 93 | output += line[:line.index(INPUT)] 94 | cmds.append((cmd_fn, cmd, output)) 95 | cmd = None 96 | cmd_fn = None 97 | output = '' 98 | line = line[line.index(INPUT):] 99 | 100 | if line.startswith(PS1): 101 | cmd_fn = run_cmd 102 | cmd = line[len(PS1):] 103 | elif line.startswith(INPUT): 104 | cmd_fn = run_input 105 | cmd = line[len(INPUT):] 106 | else: 107 | output += line + '\n' 108 | 109 | if cmd_fn is not None: 110 | cmds.append((cmd_fn, cmd, output)) 111 | 112 | return cmds 113 | 114 | 115 | def shellquote(cmd): 116 | """transform a python command-list to a shell command-string""" 117 | return ' '.join(quote(arg) for arg in cmd) 118 | 119 | 120 | def run_test(shell, tests, homedir): 121 | proc = get_proc(shell['cmd'], homedir) 122 | for test_fn, test, output in parse_tests(tests): 123 | test_fn(proc, test, output) 124 | 125 | 126 | @pytest.fixture(params=( 127 | { 128 | 'cmd': ('/bin/bash', '--noediting', '--norc', '-is'), 129 | 'errors': { 130 | 'pwd_missing': 'bash: cd: {tmpdir}/d: No such file or directory', 131 | }, 132 | }, 133 | { 134 | # -df is basically --norc 135 | # -V prevents a bizarre behavior where zsh prints lots of extra whitespace 136 | 'cmd': ('zsh', '-df', '-is', '-V', '+Z'), 137 | 'errors': { 138 | 'pwd_missing': 'cd: no such file or directory: {tmpdir}/d', 139 | }, 140 | }, 141 | )) 142 | def shell(request): 143 | return request.param 144 | 145 | 146 | def test_activates_when_cding_in(venv_path, shell, tmpdir): 147 | make_venv_in_tempdir(tmpdir) 148 | 149 | test = '''\ 150 | TEST> eval "$(aactivator init)" 151 | TEST> echo 152 | 153 | TEST> cd {venv_path} 154 | aactivator will source .activate.sh and .deactivate.sh at {venv_path}. 155 | Acceptable? (y)es (n)o (N)ever: INPUT> y 156 | aactivator will remember this: ~/.cache/aactivator/allowed 157 | aactivating... 158 | TEST> echo 159 | (aliased) 160 | TEST> cd / 161 | (aliased) deactivating... 162 | TEST> echo 163 | 164 | ''' 165 | test = test.format(venv_path=str(venv_path)) 166 | run_test(shell, test, tmpdir) 167 | 168 | 169 | def test_activates_when_cding_to_child_dir(venv_path, tmpdir, shell): 170 | make_venv_in_tempdir(tmpdir) 171 | 172 | test = '''\ 173 | TEST> eval "$(aactivator init)" 174 | TEST> echo 175 | 176 | TEST> cd {venv_path}/child-dir 177 | aactivator will source .activate.sh and .deactivate.sh at {venv_path}. 178 | Acceptable? (y)es (n)o (N)ever: INPUT> y 179 | aactivator will remember this: ~/.cache/aactivator/allowed 180 | aactivating... 181 | TEST> echo 182 | (aliased) 183 | TEST> cd / 184 | (aliased) deactivating... 185 | TEST> echo 186 | 187 | ''' 188 | test = test.format(venv_path=str(venv_path)) 189 | run_test(shell, test, tmpdir) 190 | 191 | 192 | def test_activates_subshell(venv_path, tmpdir, shell): 193 | make_venv_in_tempdir(tmpdir) 194 | 195 | test = '''\ 196 | TEST> eval "$(aactivator init)" 197 | TEST> echo 198 | 199 | TEST> cd {venv_path} 200 | aactivator will source .activate.sh and .deactivate.sh at {venv_path}. 201 | Acceptable? (y)es (n)o (N)ever: INPUT> y 202 | aactivator will remember this: ~/.cache/aactivator/allowed 203 | aactivating... 204 | TEST> echo 205 | (aliased) 206 | TEST> {shell} 207 | TEST> echo 208 | 209 | TEST> eval "$(aactivator init)" 210 | aactivating... 211 | TEST> echo 2 212 | (aliased) 2 213 | TEST> cd / 214 | (aliased) deactivating... 215 | TEST> echo 3 216 | 3 217 | TEST> exit 2>/dev/null 218 | TEST> echo 219 | (aliased) 220 | TEST> cd / 221 | (aliased) deactivating... 222 | TEST> echo 5 223 | 5 224 | ''' 225 | test = test.format( 226 | venv_path=str(venv_path), 227 | shell=shellquote(shell['cmd']), 228 | ) 229 | run_test(shell, test, tmpdir) 230 | 231 | 232 | def test_complains_when_not_activated(activate, venv_path, tmpdir, shell): 233 | make_venv_in_tempdir(tmpdir) 234 | activate.chmod(0o666) 235 | 236 | test = '''\ 237 | TEST> eval "$(aactivator init)" 238 | TEST> echo 239 | 240 | TEST> cd {venv_path} 241 | aactivator will source .activate.sh and .deactivate.sh at {venv_path}. 242 | Acceptable? (y)es (n)o (N)ever: INPUT> y 243 | aactivator will remember this: ~/.cache/aactivator/allowed 244 | aactivator: Cowardly refusing to source .activate.sh because writeable by others: .activate.sh 245 | TEST> echo 246 | 247 | aactivator: Cowardly refusing to source .activate.sh because writeable by others: .activate.sh 248 | TEST> cd / 249 | TEST> echo 250 | 251 | ''' 252 | test = test.format(venv_path=str(venv_path)) 253 | run_test(shell, test, tmpdir) 254 | 255 | 256 | def test_complains_parent_directory_insecure(venv_path, tmpdir, shell): 257 | make_venv_in_tempdir(tmpdir) 258 | venv_path.chmod(0o777) 259 | 260 | test = '''\ 261 | TEST> eval "$(aactivator init)" 262 | TEST> echo 263 | 264 | TEST> cd {venv_path} 265 | aactivator will source .activate.sh and .deactivate.sh at {venv_path}. 266 | Acceptable? (y)es (n)o (N)ever: INPUT> y 267 | aactivator will remember this: ~/.cache/aactivator/allowed 268 | aactivator: Cowardly refusing to source .activate.sh because writeable by others: . 269 | TEST> echo 270 | 271 | aactivator: Cowardly refusing to source .activate.sh because writeable by others: . 272 | TEST> cd / 273 | TEST> echo 274 | 275 | ''' 276 | test = test.format(venv_path=str(venv_path)) 277 | run_test(shell, test, tmpdir) 278 | 279 | 280 | def test_activate_but_no_deactivate(venv_path, tmpdir, deactivate, shell): 281 | make_venv_in_tempdir(tmpdir) 282 | deactivate.remove() 283 | 284 | test = '''\ 285 | TEST> eval "$(aactivator init)" 286 | TEST> echo 287 | 288 | TEST> cd {venv_path} 289 | aactivator will source .activate.sh and .deactivate.sh at {venv_path}. 290 | Acceptable? (y)es (n)o (N)ever: INPUT> y 291 | aactivator will remember this: ~/.cache/aactivator/allowed 292 | aactivating... 293 | TEST> echo 294 | (aliased) 295 | TEST> cd / 296 | (aliased) aactivator: Cannot deactivate. File missing: {deactivate} 297 | TEST> echo 298 | (aliased) 299 | ''' 300 | test = test.format( 301 | venv_path=str(venv_path), 302 | deactivate=str(deactivate), 303 | ) 304 | run_test(shell, test, tmpdir) 305 | 306 | 307 | def test_prompting_behavior(venv_path, tmpdir, shell): 308 | make_venv_in_tempdir(tmpdir) 309 | 310 | test = '''\ 311 | TEST> eval "$(aactivator init)" 312 | TEST> echo 313 | 314 | TEST> cd {venv_path} 315 | aactivator will source .activate.sh and .deactivate.sh at {venv_path}. 316 | Acceptable? (y)es (n)o (N)ever: INPUT> herpderp 317 | I didn't understand your response. 318 | 319 | Acceptable? (y)es (n)o (N)ever: INPUT> n 320 | TEST> echo 321 | 322 | TEST> echo 323 | 324 | TEST> echo 325 | 326 | TEST> cd / 327 | TEST> cd {venv_path} 328 | aactivator will source .activate.sh and .deactivate.sh at {venv_path}. 329 | Acceptable? (y)es (n)o (N)ever: INPUT> N 330 | aactivator will remember this: ~/.cache/aactivator/disallowed 331 | TEST> echo 332 | 333 | TEST> cd / 334 | TEST> echo 335 | 336 | ''' 337 | test = test.format(venv_path=str(venv_path)) 338 | run_test(shell, test, tmpdir) 339 | 340 | 341 | def test_pwd_goes_missing(tmpdir, shell): 342 | tmpdir.mkdir('d') 343 | make_venv_in_tempdir(tmpdir) 344 | 345 | test = '''\ 346 | TEST> eval "$(aactivator init)" 347 | TEST> echo 348 | 349 | TEST> cd {{tmpdir}}/d 350 | TEST> rm -rf $PWD 351 | TEST> cd $PWD 352 | {pwd_missing} 353 | TEST> echo 354 | 355 | TEST> echo 356 | 357 | ''' 358 | test = test.format( 359 | pwd_missing=shell['errors']['pwd_missing'], 360 | ).format( 361 | tmpdir=str(tmpdir), 362 | ) 363 | run_test(shell, test, tmpdir) 364 | 365 | 366 | def test_version_change(venv_path, tmpdir, shell): 367 | """If aactivator detects a version change, it will re-init and re-activate""" 368 | make_venv_in_tempdir(tmpdir) 369 | 370 | test = '''\ 371 | TEST> eval "$(aactivator init)" 372 | TEST> cd {venv_path} 373 | aactivator will source .activate.sh and .deactivate.sh at {venv_path}. 374 | Acceptable? (y)es (n)o (N)ever: INPUT> y 375 | aactivator will remember this: ~/.cache/aactivator/allowed 376 | aactivating... 377 | TEST> export AACTIVATOR_VERSION=0 378 | aactivating... 379 | TEST> echo $AACTIVATOR_VERSION 380 | (aliased) {version} 381 | ''' 382 | test = test.format( 383 | venv_path=str(venv_path), 384 | version=aactivator.__version__, 385 | ) 386 | run_test(shell, test, tmpdir) 387 | 388 | 389 | def test_cd_dash(venv_path, tmpdir, shell): 390 | make_venv_in_tempdir(tmpdir) 391 | venv2 = make_venv_in_tempdir(tmpdir, 'venv2') 392 | 393 | test = '''\ 394 | TEST> eval "$(aactivator init)" 395 | TEST> cd {venv_path}/child-dir 396 | aactivator will source .activate.sh and .deactivate.sh at {venv_path}. 397 | Acceptable? (y)es (n)o (N)ever: INPUT> y 398 | aactivator will remember this: ~/.cache/aactivator/allowed 399 | aactivating... 400 | TEST> pwd 401 | {venv_path}/child-dir 402 | TEST> cd {venv2}/child-dir 403 | aactivator will source .activate.sh and .deactivate.sh at {venv2}. 404 | Acceptable? (y)es (n)o (N)ever: INPUT> y 405 | aactivator will remember this: ~/.cache/aactivator/allowed 406 | (aliased) deactivating... 407 | aactivating... 408 | TEST> cd - > /dev/null 409 | (aliased) deactivating... 410 | aactivating... 411 | TEST> pwd 412 | {venv_path}/child-dir 413 | TEST> cd - > /dev/null 414 | (aliased) deactivating... 415 | aactivating... 416 | TEST> pwd 417 | {venv2}/child-dir 418 | ''' 419 | test = test.format( 420 | venv_path=str(venv_path), 421 | venv2=str(venv2), 422 | ) 423 | run_test(shell, test, tmpdir) 424 | 425 | 426 | def test_aactivator_goes_missing_no_output(venv_path, shell, tmpdir): 427 | make_venv_in_tempdir(tmpdir) 428 | 429 | exe = tmpdir.join('exe').strpath 430 | src = os.path.join(os.path.dirname(sys.executable), 'aactivator') 431 | shutil.copy(src, exe) 432 | 433 | test = '''\ 434 | TEST> eval "$({exe} init)" 435 | TEST> rm {exe} 436 | TEST> echo 437 | 438 | TEST> cd {venv_path} 439 | TEST> echo 440 | 441 | ''' 442 | test = test.format(venv_path=str(venv_path), exe=exe) 443 | run_test(shell, test, tmpdir) 444 | -------------------------------------------------------------------------------- /tests/unit_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | from __future__ import unicode_literals 5 | 6 | import functools 7 | import sys 8 | 9 | import pytest 10 | 11 | import aactivator 12 | from testing import make_venv_in_tempdir 13 | 14 | 15 | @pytest.fixture 16 | def f_path(tmpdir): 17 | return tmpdir.join('f') 18 | 19 | 20 | @pytest.fixture 21 | def inactive_env(tmpdir): 22 | return ( 23 | ('HOME', str(tmpdir)), 24 | ('AACTIVATOR_VERSION', aactivator.__version__), 25 | ) 26 | 27 | 28 | @pytest.fixture 29 | def allowed_config(tmpdir): 30 | return tmpdir.join('.cache/aactivator/allowed') 31 | 32 | 33 | @pytest.fixture 34 | def disallowed_config(tmpdir): 35 | return tmpdir.join('.cache/aactivator/disallowed') 36 | 37 | 38 | @pytest.fixture 39 | def active_env(venv_path, inactive_env): 40 | return inactive_env + (('AACTIVATOR_ACTIVE', str(venv_path)),) 41 | 42 | 43 | def test_is_safe_to_source_fine(f_path): 44 | f_path.open('a').close() 45 | assert aactivator.insecure(str(f_path)) is None 46 | 47 | 48 | def test_is_safe_to_source_not_fine(f_path): 49 | f_path.open('a').close() 50 | f_path.chmod(0o666) 51 | assert aactivator.insecure(str(f_path)) == str(f_path) 52 | 53 | 54 | def test_is_safe_to_source_also_not_fine(f_path): 55 | f_path.open('a').close() 56 | f_path.chmod(0o777) 57 | assert aactivator.insecure(str(f_path)) == str(f_path) 58 | 59 | 60 | def test_directory_writeable_not_fine(tmpdir, f_path): 61 | f_path.open('a').close() 62 | tmpdir.chmod(0o777) 63 | assert aactivator.insecure(str(f_path)) == str(tmpdir) 64 | 65 | 66 | def test_security_check_for_path_non_sourceable(tmpdir, f_path): 67 | f_path.open('a').close() 68 | f_path.chmod(0o666) 69 | with tmpdir.as_cwd(): 70 | assert ( 71 | aactivator.security_check(str(f_path)) == 72 | 'aactivator: Cowardly refusing to source f because writeable by others: f' 73 | ) 74 | 75 | 76 | def test_security_check_for_path_sourceable(f_path): 77 | f_path.open('a').close() 78 | assert aactivator.security_check(str(f_path)) is None 79 | 80 | 81 | def test_security_check_for_path_nonexistant(f_path): 82 | assert ( 83 | aactivator.security_check(str(f_path)) == 84 | 'aactivator: File does not exist: ' + str(f_path) 85 | ) 86 | 87 | 88 | def test_get_output_nothing_special(tmpdir, inactive_env): 89 | output = aactivator.get_output( 90 | dict(inactive_env), 91 | str(tmpdir), 92 | ) 93 | assert output == '' 94 | 95 | 96 | def test_get_output_already_sourced(tmpdir, venv_path, active_env): 97 | make_venv_in_tempdir(tmpdir) 98 | output = aactivator.get_output( 99 | dict(active_env), 100 | str(venv_path), 101 | lambda: 'y', 102 | ) 103 | assert output == '' 104 | 105 | 106 | def test_get_output_sourced_not_in_directory(tmpdir, venv_path, active_env): 107 | make_venv_in_tempdir(tmpdir) 108 | output = aactivator.get_output( 109 | dict(active_env), 110 | str(tmpdir), 111 | ) 112 | assert ( 113 | output == 114 | '''\ 115 | OLDPWD_bak="$OLDPWD" && 116 | cd {venv_path} && 117 | aactivator security-check .deactivate.sh && 118 | source ./.deactivate.sh 119 | unset AACTIVATOR_ACTIVE && 120 | cd "$OLDPWD_bak" && 121 | cd {tmpdir} && 122 | unset OLDPWD_bak'''.format(venv_path=str(venv_path), tmpdir=str(tmpdir)) 123 | ) 124 | 125 | 126 | def test_get_output_sourced_deeper_in_directory(tmpdir, venv_path, active_env): 127 | make_venv_in_tempdir(tmpdir) 128 | deeper = venv_path.mkdir('deeper') 129 | 130 | output = aactivator.get_output( 131 | dict(active_env), 132 | str(deeper), 133 | lambda: 'y', 134 | ) 135 | assert output == '' 136 | 137 | 138 | def test_get_output_sourced_deeper_venv(tmpdir, venv_path, active_env): 139 | make_venv_in_tempdir(tmpdir) 140 | deeper = make_venv_in_tempdir(venv_path, 'deeper') 141 | 142 | output = aactivator.get_output( 143 | dict(active_env), 144 | str(deeper), 145 | lambda: 'y', 146 | ) 147 | assert ( 148 | output == 149 | '''\ 150 | OLDPWD_bak="$OLDPWD" && 151 | cd {venv_path} && 152 | aactivator security-check .deactivate.sh && 153 | source ./.deactivate.sh 154 | unset AACTIVATOR_ACTIVE && 155 | cd "$OLDPWD_bak" && 156 | cd {venv_path}/deeper && 157 | unset OLDPWD_bak && 158 | aactivator security-check .activate.sh && 159 | source ./.activate.sh && 160 | export AACTIVATOR_ACTIVE={venv_path}/deeper'''.format(venv_path=str(venv_path)) 161 | ) 162 | 163 | 164 | def test_not_sourced_sources(tmpdir, venv_path, inactive_env): 165 | make_venv_in_tempdir(tmpdir) 166 | output = aactivator.get_output( 167 | dict(inactive_env), 168 | str(venv_path), 169 | lambda: 'y', 170 | ) 171 | assert ( 172 | output == 173 | '''\ 174 | aactivator security-check .activate.sh && 175 | source ./.activate.sh && 176 | export AACTIVATOR_ACTIVE=''' + str(venv_path) 177 | ) 178 | 179 | 180 | def test_get_output_change_venv(tmpdir, venv_path, active_env): 181 | make_venv_in_tempdir(tmpdir) 182 | venv2 = make_venv_in_tempdir(tmpdir, 'venv2') 183 | output = aactivator.get_output( 184 | dict(active_env), 185 | str(venv2), 186 | lambda: 'y', 187 | ) 188 | assert ( 189 | output == 190 | '''\ 191 | OLDPWD_bak="$OLDPWD" && 192 | cd {venv_path} && 193 | aactivator security-check .deactivate.sh && 194 | source ./.deactivate.sh 195 | unset AACTIVATOR_ACTIVE && 196 | cd "$OLDPWD_bak" && 197 | cd {venv_path}2 && 198 | unset OLDPWD_bak && 199 | aactivator security-check .activate.sh && 200 | source ./.activate.sh && 201 | export AACTIVATOR_ACTIVE={venv_path}2'''.format(venv_path=str(venv_path)) 202 | ) 203 | 204 | 205 | def test_get_output_pwd_goes_missing(tmpdir, venv_path, inactive_env): 206 | make_venv_in_tempdir(tmpdir) 207 | with venv_path.as_cwd(): 208 | venv_path.remove(rec=1) 209 | output = aactivator.get_output( 210 | dict(inactive_env), 211 | str(venv_path), 212 | lambda: 'n', 213 | ) 214 | assert output == '' 215 | 216 | 217 | def config(inactive_env, answer): 218 | return aactivator.ActivateConfig( 219 | dict(inactive_env), 220 | lambda: print(answer, file=sys.stderr) or answer, 221 | ) 222 | 223 | 224 | @pytest.fixture 225 | def yes_config(inactive_env): 226 | return lambda: config(inactive_env, 'y') 227 | 228 | 229 | @pytest.fixture 230 | def no_config(inactive_env): 231 | return lambda: config(inactive_env, 'n') 232 | 233 | 234 | @pytest.fixture 235 | def never_config(inactive_env): 236 | return lambda: config(inactive_env, 'N') 237 | 238 | 239 | @pytest.fixture 240 | def eof_config(inactive_env): 241 | def raise_eoferror(): 242 | raise EOFError() 243 | return functools.partial( 244 | aactivator.ActivateConfig, 245 | dict(inactive_env), 246 | raise_eoferror, 247 | ) 248 | 249 | 250 | def test_prompt_loop_answer_yes(tmpdir, venv_path, yes_config, allowed_config): 251 | make_venv_in_tempdir(tmpdir) 252 | assert yes_config().find_allowed(str(venv_path)) == str(venv_path) 253 | assert allowed_config.check(file=1) 254 | assert allowed_config.read() == venv_path + '\n' 255 | 256 | 257 | def test_no_config_not_allowed(tmpdir, venv_path, no_config, allowed_config, disallowed_config): 258 | make_venv_in_tempdir(tmpdir) 259 | assert no_config().find_allowed(str(venv_path)) is None 260 | # Saying no should not permanently save anything 261 | assert allowed_config.check(exists=0) 262 | assert disallowed_config.check(exists=0) 263 | 264 | 265 | def test_eof_treated_like_no(tmpdir, venv_path, eof_config, allowed_config, disallowed_config): 266 | make_venv_in_tempdir(tmpdir) 267 | assert eof_config().find_allowed(str(venv_path)) is None 268 | # Saying no should not permanently save anything 269 | assert allowed_config.check(exists=0) 270 | assert disallowed_config.check(exists=0) 271 | 272 | 273 | def test_never_config_not_allowed(tmpdir, venv_path, never_config, allowed_config, disallowed_config): 274 | make_venv_in_tempdir(tmpdir) 275 | assert never_config().find_allowed(str(venv_path)) is None 276 | assert disallowed_config.check(file=1) 277 | assert disallowed_config.read() == str(venv_path) + '\n' 278 | 279 | 280 | def test_yes_config_is_remembered(tmpdir, venv_path, yes_config, no_config): 281 | make_venv_in_tempdir(tmpdir) 282 | assert yes_config().find_allowed(str(venv_path)) == str(venv_path) 283 | assert no_config().find_allowed(str(venv_path)) == str(venv_path) 284 | 285 | 286 | def test_never_is_remembered(tmpdir, venv_path, never_config, yes_config): 287 | make_venv_in_tempdir(tmpdir) 288 | assert never_config().find_allowed(str(venv_path)) is None 289 | assert yes_config().find_allowed(str(venv_path)) is None 290 | 291 | 292 | def test_not_owned_by_me_is_not_activatable(tmpdir, activate, yes_config): 293 | make_venv_in_tempdir(tmpdir) 294 | assert yes_config().is_allowed(str(activate), _getuid=lambda: -1) is False 295 | 296 | 297 | def test_no_is_remembered_until_cd_out(venv_path, tmpdir, no_config, yes_config): 298 | venv = make_venv_in_tempdir(tmpdir) 299 | assert no_config().find_allowed(str(venv_path)) is None 300 | assert yes_config().find_allowed(str(venv_path)) is None 301 | 302 | # cd to a child directory shouldn't forget the answer 303 | child_dir = venv.join('child-dir') 304 | assert yes_config().find_allowed(str(child_dir)) is None 305 | 306 | # cd to a parent directory directory (and back) should 307 | assert yes_config().find_allowed('/') is None 308 | assert yes_config().find_allowed(str(child_dir)) == str(venv_path) 309 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37,py38,py39,py310,py311 3 | skip_missing_interpreters = true 4 | 5 | [testenv] 6 | passenv = TMP 7 | deps = -rrequirements-dev.txt 8 | commands = 9 | coverage erase 10 | coverage run -m pytest -vv {posargs:tests} 11 | coverage combine 12 | coverage report --show-missing 13 | pre-commit run --all-files 14 | 15 | [flake8] 16 | max-line-length = 131 17 | 18 | [pep8] 19 | ignore = E501 20 | --------------------------------------------------------------------------------