diff --git a/.appveyor.yml b/.appveyor.yml index 89e5b74..ed614ef 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -1,14 +1,23 @@ +image: Visual Studio 2017 +build: off +test: off +cache: + - '%LOCALAPPDATA%\pip\Cache' + +skip_branch_with_pr: true +branches: + only: + - master + environment: matrix: - PYTHON: "C:\\Python27-x64" - - PYTHON: "C:\\Python35-x64" - - PYTHON: "C:\\Python36-x64" + - PYTHON: "C:\\Python37-x64" install: - - "%PYTHON%\\python.exe -m pip install pytest" - - "%PYTHON%\\python.exe setup.py --quiet clean develop" - -build: off + - "%PYTHON%\\python.exe -m pip install -U pip" + - "%PYTHON%\\python.exe -m pip install tox codecov" test_script: - - "%PYTHON%\\python.exe -m pytest" + - "%PYTHON%\\python.exe -m tox" + - "%PYTHON%\\python.exe -m tox -e codecov" diff --git a/.codecov.yml b/.codecov.yml index 2bfc815..f9ef2a9 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,9 +1,9 @@ comment: false coverage: - status: - patch: - default: - target: '100' - project: - default: - target: '100' + status: + patch: + default: + target: '80' + project: + default: + target: '80' diff --git a/.coveragerc b/.coveragerc index e889444..98485da 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,10 +1,23 @@ [run] +data_file = tests/coverage/.coverage +branch = True +parallel = True source = userpath tests -branch = True omit = userpath/__main__.py + userpath/cli.py + +[paths] +userpath = + userpath + /home/userpath/userpath + c:\*\userpath\userpath +tests = + tests + /home/userpath/tests + c:\*\userpath\tests [report] exclude_lines = diff --git a/.gitignore b/.gitignore index fe55879..dbc515a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .cache/ .coverage .idea/ +.tox/ .vscode/ userpath.egg-info/ build/ diff --git a/.travis.yml b/.travis.yml index e21b6bd..290ca52 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,23 +1,22 @@ +dist: xenial language: python +services: + - docker + +branches: + only: + - master + matrix: - include: - - python: 2.7 - env: TOXENV=py27 - - python: 3.5 - env: TOXENV=py35 - - python: 3.6 - env: TOXENV=py36 - - python: 3.7 - dist: xenial - sudo: true - env: TOXENV=py37 - - python: pypy2.7-5.8.0 - env: TOXENV=pypy - - python: pypy3.5-5.8.0 - env: TOXENV=pypy3 + include: + - python: 2.7 + env: TOXENV=py27 + - python: 3.7 + env: TOXENV=py37 install: - - pip install tox + - pip install tox codecov -script: tox +script: + - tox -e $TOXENV,coverage,codecov diff --git a/README.rst b/README.rst index 40032b6..24e5cfe 100644 --- a/README.rst +++ b/README.rst @@ -1,25 +1,37 @@ userpath ======== -.. image:: https://img.shields.io/pypi/v/userpath.svg?style=flat-square - :target: https://pypi.org/project/userpath - :alt: Latest PyPI version - -.. image:: https://img.shields.io/travis/ofek/userpath/master.svg?style=flat-square +.. image:: https://img.shields.io/travis/ofek/userpath/master.svg?logo=travis&label=Travis%20CI :target: https://travis-ci.org/ofek/userpath - :alt: Travis CI + :alt: CI - Travis -.. image:: https://img.shields.io/appveyor/ci/ofek/userpath/master.svg?style=flat-square +.. image:: https://img.shields.io/appveyor/ci/ofek/userpath/master.svg?logo=appveyor&label=AppVeyor :target: https://ci.appveyor.com/project/ofek/userpath - :alt: AppVeyor CI + :alt: CI - AppVeyor + +.. image:: https://img.shields.io/codecov/c/github/ofek/userpath/master.svg?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNDgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CgogPGc+CiAgPHRpdGxlPmJhY2tncm91bmQ8L3RpdGxlPgogIDxyZWN0IGZpbGw9Im5vbmUiIGlkPSJjYW52YXNfYmFja2dyb3VuZCIgaGVpZ2h0PSI0MDIiIHdpZHRoPSI1ODIiIHk9Ii0xIiB4PSItMSIvPgogPC9nPgogPGc+CiAgPHRpdGxlPkxheWVyIDE8L3RpdGxlPgogIDxwYXRoIGlkPSJzdmdfMSIgZmlsbC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmZmZmZmIiBkPSJtMjUuMDE0LDBjLTEzLjc4NCwwLjAxIC0yNS4wMDQsMTEuMTQ5IC0yNS4wMTQsMjQuODMybDAsMC4wNjJsNC4yNTQsMi40ODJsMC4wNTgsLTAuMDM5YTEyLjIzOCwxMi4yMzggMCAwIDEgOS4wNzgsLTEuOTI4YTExLjg0NCwxMS44NDQgMCAwIDEgNS45OCwyLjk3NWwwLjczLDAuNjhsMC40MTMsLTAuOTA0YzAuNCwtMC44NzQgMC44NjIsLTEuNjk2IDEuMzc0LC0yLjQ0M2MwLjIwNiwtMC4zIDAuNDMzLC0wLjYwNCAwLjY5MiwtMC45MjlsMC40MjcsLTAuNTM1bC0wLjUyNiwtMC40NGExNy40NSwxNy40NSAwIDAgMCAtOC4xLC0zLjc4MWExNy44NTMsMTcuODUzIDAgMCAwIC04LjM3NSwwLjQ5YzIuMDIzLC04Ljg2OCA5LjgyLC0xNS4wNSAxOS4wMjcsLTE1LjA1N2M1LjE5NSwwIDEwLjA3OCwyLjAwNyAxMy43NTIsNS42NTJjMi42MTksMi41OTggNC40MjIsNS44MzUgNS4yMjQsOS4zNzJhMTcuOTA4LDE3LjkwOCAwIDAgMCAtNS4yMDgsLTAuNzlsLTAuMzE4LC0wLjAwMWExOC4wOTYsMTguMDk2IDAgMCAwIC0yLjA2NywwLjE1M2wtMC4wODcsMC4wMTJjLTAuMzAzLDAuMDQgLTAuNTcsMC4wODEgLTAuODEzLDAuMTI2Yy0wLjExOSwwLjAyIC0wLjIzNywwLjA0NSAtMC4zNTUsMC4wNjhjLTAuMjgsMC4wNTcgLTAuNTU0LDAuMTE5IC0wLjgxNiwwLjE4NWwtMC4yODgsMC4wNzNjLTAuMzM2LDAuMDkgLTAuNjc1LDAuMTkxIC0xLjAwNiwwLjNsLTAuMDYxLDAuMDJjLTAuNzQsMC4yNTEgLTEuNDc4LDAuNTU4IC0yLjE5LDAuOTE0bC0wLjA1NywwLjAyOWMtMC4zMTYsMC4xNTggLTAuNjM2LDAuMzMzIC0wLjk3OCwwLjUzNGwtMC4wNzUsMC4wNDVhMTYuOTcsMTYuOTcgMCAwIDAgLTQuNDE0LDMuNzhsLTAuMTU3LDAuMTkxYy0wLjMxNywwLjM5NCAtMC41NjcsMC43MjcgLTAuNzg3LDEuMDQ4Yy0wLjE4NCwwLjI3IC0wLjM2OSwwLjU2IC0wLjYsMC45NDJsLTAuMTI2LDAuMjE3Yy0wLjE4NCwwLjMxOCAtMC4zNDgsMC42MjIgLTAuNDg3LDAuOWwtMC4wMzMsMC4wNjFjLTAuMzU0LDAuNzExIC0wLjY2MSwxLjQ1NSAtMC45MTcsMi4yMTRsLTAuMDM2LDAuMTExYTE3LjEzLDE3LjEzIDAgMCAwIC0wLjg1NSw1LjY0NGwwLjAwMywwLjIzNGEyMy41NjUsMjMuNTY1IDAgMCAwIDAuMDQzLDAuODIyYzAuMDEsMC4xMyAwLjAyMywwLjI1OSAwLjAzNiwwLjM4OGMwLjAxNSwwLjE1OCAwLjAzNCwwLjMxNiAwLjA1MywwLjQ3MWwwLjAxMSwwLjA4OGwwLjAyOCwwLjIxNGMwLjAzNywwLjI2NCAwLjA4LDAuNTI1IDAuMTMsMC43ODdjMC41MDMsMi42MzcgMS43Niw1LjI3NCAzLjYzNSw3LjYyNWwwLjA4NSwwLjEwNmwwLjA4NywtMC4xMDRjMC43NDgsLTAuODg0IDIuNjAzLC0zLjY4NyAyLjc2LC01LjM2OWwwLjAwMywtMC4wMzFsLTAuMDE1LC0wLjAyOGExMS43MzYsMTEuNzM2IDAgMCAxIC0xLjMzMywtNS40MDdjMCwtNi4yODQgNC45NCwtMTEuNTAyIDExLjI0MywtMTEuODhsMC40MTQsLTAuMDE1YzIuNTYxLC0wLjA1OCA1LjA2NCwwLjY3MyA3LjIzLDIuMTM2bDAuMDU4LDAuMDM5bDQuMTk3LC0yLjQ0bDAuMDU1LC0wLjAzM2wwLC0wLjA2MmMwLjAwNiwtNi42MzIgLTIuNTkyLC0xMi44NjUgLTcuMzE0LC0xNy41NTFjLTQuNzE2LC00LjY3OSAtMTAuOTkxLC03LjI1NSAtMTcuNjcyLC03LjI1NSIvPgogPC9nPgo8L3N2Zz4=&label=Codecov + :target: https://codecov.io/github/ofek/userpath?branch=master + :alt: Codecov +| +.. image:: https://img.shields.io/pypi/pyversions/userpath.svg?logo=python&label=Python&logoColor=gold + :target: https://pypi.org/project/userpath + :alt: PyPI - Supported Python versions -.. image:: https://img.shields.io/pypi/pyversions/userpath.svg?style=flat-square +.. image:: https://img.shields.io/pypi/v/userpath.svg?logo=python&label=PyPI&logoColor=gold :target: https://pypi.org/project/userpath - :alt: Supported Python versions + :alt: PyPI - Version -.. image:: https://img.shields.io/pypi/l/userpath.svg?style=flat-square +.. image:: https://img.shields.io/pypi/dm/userpath.svg?color=blue&label=Downloads&logo=python&logoColor=gold + :target: https://pypi.org/project/userpath + :alt: PyPI - Downloads +| +.. image:: https://img.shields.io/badge/License-MIT%2FApache--2.0-9400d3.svg :target: https://choosealicense.com/licenses - :alt: License + :alt: License: MIT/Apache-2.0 + +.. image:: https://img.shields.io/badge/say-thanks-ff69b4.svg + :target: https://saythanks.io/to/ofek + :alt: Say Thanks ----- @@ -38,9 +50,8 @@ Installation userpath is distributed on `PyPI `_ as a universal wheel and is available on Linux/macOS and Windows and supports -Python 2.6-2.7/3.3+ and PyPy. - -.. code-block:: bash +Python 2.7/3.6+ and PyPy. +:: $ pip install userpath @@ -48,8 +59,7 @@ Commands -------- Only 3! - -.. code-block:: bash +:: $ userpath -h Usage: userpath [OPTIONS] COMMAND [ARGS]... diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7093b61 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +coverage +pytest diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d6e1198..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ --e . diff --git a/setup.py b/setup.py index 52836e9..5a32b51 100644 --- a/setup.py +++ b/setup.py @@ -13,12 +13,12 @@ with open('README.rst', 'r', encoding='utf-8') as f: readme = f.read() -REQUIRES = ['click'] +REQUIRES = ['click', 'distro'] setup( name='userpath', version=version, - description='Cross-platform tool for adding locations to the user PATH, no sudo/runas required!', + description='Cross-platform tool for adding locations to the user PATH, no elevated privileges required!', long_description=readme, author='Ofek Lev', author_email='ofekmeister@gmail.com', @@ -41,20 +41,15 @@ 'License :: OSI Approved :: Apache Software License', 'Natural Language :: English', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy' + 'Programming Language :: Python :: Implementation :: PyPy', ], install_requires=REQUIRES, - tests_require=['coverage', 'pytest'], - - packages=find_packages(), + packages=['userpath'], entry_points={ 'console_scripts': [ 'userpath = userpath.cli:userpath', diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..97c8fc1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,81 @@ +import os +import subprocess +from itertools import chain + +import pytest + +from userpath.shells import SHELLS + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) + + +def pytest_configure(config): + # pytest will emit warnings if these aren't registered ahead of time + for shell in sorted(SHELLS): + config.addinivalue_line('markers', '{shell}: marker to only run tests for {shell}'.format(shell=shell)) + + +@pytest.fixture(scope='class') +def shell_test(request): + if 'SHELL' in os.environ: + yield + else: + compose_file = os.path.join(HERE, 'docker', 'docker-compose.yaml') + shell_name = request.module.SHELL_NAME + dockerfile = getattr(request.cls, 'DOCKERFILE', 'debian') + container = '{}-{}'.format(shell_name, dockerfile) + + tox_env = os.environ['TOX_ENV_NAME'] + python_version = '.'.join(tox_env.replace('py', '')) + + try: + os.environ['SHELL'] = shell_name + os.environ['DOCKERFILE'] = dockerfile + os.environ['PYTHON_VERSION'] = python_version + subprocess.check_call(['docker-compose', '-f', compose_file, 'up', '-d', '--build']) + + # Python gets really upset when compiled files from different paths and/or platforms are encountered + clean_package() + + yield lambda test_name: subprocess.Popen( + [ + 'docker', + 'exec', + '-w', + '/home/userpath', + container, + 'coverage', + 'run', + '-m', + 'pytest', + 'tests/{}::{}::{}'.format(os.path.basename(request.module.__file__), request.node.name, test_name), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + finally: + # Clean up for the next tox invocation + clean_package() + + # Tear down without checking for errors + subprocess.call(['docker-compose', '-f', compose_file, 'down']) + del os.environ['SHELL'] + del os.environ['DOCKERFILE'] + del os.environ['PYTHON_VERSION'] + + +def clean_package(): + to_delete = [] + walker = os.walk(ROOT) + + top = next(walker) + top[1].remove('.tox') + + for root, dirs, files in chain((top,), walker): + for f in files: + if f.endswith('.pyc'): + to_delete.append(os.path.join(root, f)) + + for f in to_delete: + os.remove(f) diff --git a/tests/coverage/.gitignore b/tests/coverage/.gitignore new file mode 100644 index 0000000..4b15f15 --- /dev/null +++ b/tests/coverage/.gitignore @@ -0,0 +1,3 @@ +# Ignore this directory used for coverage aggregation +* +!.gitignore diff --git a/tests/docker/debian b/tests/docker/debian new file mode 100644 index 0000000..8ece2f4 --- /dev/null +++ b/tests/docker/debian @@ -0,0 +1,10 @@ +ARG PYTHON_VERSION +FROM python:${PYTHON_VERSION}-stretch + +RUN apt-get update \ + && apt-get --no-install-recommends -y install fish zsh + +COPY requirements.txt / +RUN pip install -r requirements.txt + +CMD ["tail", "-f", "/dev/null"] diff --git a/tests/docker/docker-compose.yaml b/tests/docker/docker-compose.yaml new file mode 100644 index 0000000..6b69ca0 --- /dev/null +++ b/tests/docker/docker-compose.yaml @@ -0,0 +1,15 @@ +version: '3' + +services: + + userpath: + container_name: ${SHELL}-${DOCKERFILE} + build: + context: . + dockerfile: ./${DOCKERFILE} + args: + PYTHON_VERSION: ${PYTHON_VERSION} + environment: + - SHELL=${SHELL} + volumes: + - ./../../:/home/userpath diff --git a/tests/docker/requirements.txt b/tests/docker/requirements.txt new file mode 100644 index 0000000..c8239e6 --- /dev/null +++ b/tests/docker/requirements.txt @@ -0,0 +1,10 @@ +# Deps +click +distro + +# Test deps +coverage +pytest + +# xonsh shell, if we can +xonsh; python_version > '3.0' diff --git a/tests/test_bash.py b/tests/test_bash.py new file mode 100644 index 0000000..c0f8e5c --- /dev/null +++ b/tests/test_bash.py @@ -0,0 +1,65 @@ +import pytest +import userpath + +from .utils import SKIP_WINDOWS_CI, get_random_path + +SHELL_NAME = 'bash' + +pytestmark = [SKIP_WINDOWS_CI, pytest.mark.bash] + + +@pytest.mark.usefixtures('shell_test') +class TestDebian(object): + DOCKERFILE = 'debian' + + def test_prepend(self, request, shell_test): + if shell_test is None: + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.prepend(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_prepend_multiple(self, request, shell_test): + if shell_test is None: + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.prepend(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_append(self, request, shell_test): + if shell_test is None: + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.append(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_append_multiple(self, request, shell_test): + if shell_test is None: + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.append(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') diff --git a/tests/test_core.py b/tests/test_core.py deleted file mode 100644 index 9c666e8..0000000 --- a/tests/test_core.py +++ /dev/null @@ -1,38 +0,0 @@ -from base64 import urlsafe_b64encode -from os import urandom - -import userpath - - -def test_prepend(): - location = urlsafe_b64encode(urandom(5)).decode() - assert not userpath.in_current_path(location) - assert userpath.prepend(location) - assert userpath.in_new_path(location) - assert userpath.need_shell_restart(location) - - -def test_prepend_multiple(): - location1 = urlsafe_b64encode(urandom(5)).decode() - location2 = urlsafe_b64encode(urandom(5)).decode() - assert not userpath.in_current_path([location1, location2]) - assert userpath.prepend([location1, location2]) - assert userpath.in_new_path([location1, location2]) - assert userpath.need_shell_restart([location1, location2]) - - -def test_append(): - location = urlsafe_b64encode(urandom(5)).decode() - assert not userpath.in_current_path(location) - assert userpath.append(location) - assert userpath.in_new_path(location) - assert userpath.need_shell_restart(location) - - -def test_append_multiple(): - location1 = urlsafe_b64encode(urandom(5)).decode() - location2 = urlsafe_b64encode(urandom(5)).decode() - assert not userpath.in_current_path([location1, location2]) - assert userpath.append([location1, location2]) - assert userpath.in_new_path([location1, location2]) - assert userpath.need_shell_restart([location1, location2]) diff --git a/tests/test_fish.py b/tests/test_fish.py new file mode 100644 index 0000000..be3fad4 --- /dev/null +++ b/tests/test_fish.py @@ -0,0 +1,65 @@ +import pytest +import userpath + +from .utils import SKIP_WINDOWS_CI, get_random_path + +SHELL_NAME = 'fish' + +pytestmark = [SKIP_WINDOWS_CI, pytest.mark.fish] + + +@pytest.mark.usefixtures('shell_test') +class TestDebian(object): + DOCKERFILE = 'debian' + + def test_prepend(self, request, shell_test): + if shell_test is None: + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.prepend(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_prepend_multiple(self, request, shell_test): + if shell_test is None: + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.prepend(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_append(self, request, shell_test): + if shell_test is None: + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.append(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_append_multiple(self, request, shell_test): + if shell_test is None: + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.append(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') diff --git a/tests/test_sh.py b/tests/test_sh.py new file mode 100644 index 0000000..18ae3e4 --- /dev/null +++ b/tests/test_sh.py @@ -0,0 +1,65 @@ +import pytest +import userpath + +from .utils import SKIP_WINDOWS_CI, get_random_path + +SHELL_NAME = 'sh' + +pytestmark = [SKIP_WINDOWS_CI, pytest.mark.sh] + + +@pytest.mark.usefixtures('shell_test') +class TestDebian(object): + DOCKERFILE = 'debian' + + def test_prepend(self, request, shell_test): + if shell_test is None: + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.prepend(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_prepend_multiple(self, request, shell_test): + if shell_test is None: + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.prepend(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_append(self, request, shell_test): + if shell_test is None: + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.append(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_append_multiple(self, request, shell_test): + if shell_test is None: + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.append(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') diff --git a/tests/test_windows.py b/tests/test_windows.py new file mode 100644 index 0000000..643e2a9 --- /dev/null +++ b/tests/test_windows.py @@ -0,0 +1,38 @@ +import pytest +import userpath + +from .utils import ON_WINDOWS_CI, get_random_path + +pytestmark = pytest.mark.skipif(not ON_WINDOWS_CI, reason='Tests only for throwaway Windows VMs on CI') + + +def test_prepend(): + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.prepend(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + + +def test_prepend_multiple(): + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.prepend(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + + +def test_append(): + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.append(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + + +def test_append_multiple(): + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.append(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) diff --git a/tests/test_xonsh.py b/tests/test_xonsh.py new file mode 100644 index 0000000..5ed2e54 --- /dev/null +++ b/tests/test_xonsh.py @@ -0,0 +1,65 @@ +import pytest +import userpath + +from .utils import SKIP_WINDOWS_CI, get_random_path + +SHELL_NAME = 'xonsh' + +pytestmark = [SKIP_WINDOWS_CI, pytest.mark.xonsh] + + +@pytest.mark.usefixtures('shell_test') +class TestDebian(object): + DOCKERFILE = 'debian' + + def test_prepend(self, request, shell_test): + if shell_test is None: + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.prepend(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_prepend_multiple(self, request, shell_test): + if shell_test is None: + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.prepend(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_append(self, request, shell_test): + if shell_test is None: + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.append(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_append_multiple(self, request, shell_test): + if shell_test is None: + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.append(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') diff --git a/tests/test_zsh.py b/tests/test_zsh.py new file mode 100644 index 0000000..597f6f0 --- /dev/null +++ b/tests/test_zsh.py @@ -0,0 +1,65 @@ +import pytest +import userpath + +from .utils import SKIP_WINDOWS_CI, get_random_path + +SHELL_NAME = 'zsh' + +pytestmark = [SKIP_WINDOWS_CI, pytest.mark.zsh] + + +@pytest.mark.usefixtures('shell_test') +class TestDebian(object): + DOCKERFILE = 'debian' + + def test_prepend(self, request, shell_test): + if shell_test is None: + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.prepend(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_prepend_multiple(self, request, shell_test): + if shell_test is None: + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.prepend(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_append(self, request, shell_test): + if shell_test is None: + location = get_random_path() + assert not userpath.in_current_path(location) + assert userpath.append(location, check=True) + assert userpath.in_new_path(location) + assert userpath.need_shell_restart(location) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') + + def test_append_multiple(self, request, shell_test): + if shell_test is None: + locations = [get_random_path(), get_random_path()] + assert not userpath.in_current_path(locations) + assert userpath.append(locations, check=True) + assert userpath.in_new_path(locations) + assert userpath.need_shell_restart(locations) + else: + process = shell_test(request.node.name) + stdout, stderr = process.communicate() + + assert process.returncode == 0, (stdout + stderr).decode('utf-8') diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..8799b0b --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,11 @@ +import os +from base64 import urlsafe_b64encode + +import pytest + +ON_WINDOWS_CI = 'APPVEYOR' in os.environ +SKIP_WINDOWS_CI = pytest.mark.skipif(ON_WINDOWS_CI, reason='Tests not run on Windows CI') + + +def get_random_path(): + return urlsafe_b64encode(os.urandom(5)).decode() diff --git a/tox.ini b/tox.ini index 079bca5..f86f13e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,41 @@ [tox] +skip_missing_interpreters = true envlist = - py27, - py35, - py36, - py37, - pypy, - pypy3, + py{27,37} + coverage [testenv] -passenv = * +usedevelop = true +passenv = APPVEYOR deps = - pytest + -rrequirements-dev.txt commands = - python setup.py --quiet clean develop - pytest + coverage run -m pytest -v {posargs} + +[testenv:py27] +commands = + coverage run -m pytest -v -m "not xonsh" {posargs} + +[testenv:coverage] +skip_install = true +deps = coverage +commands = + coverage combine + coverage report + +[testenv:codecov] +skip_install = true +passenv = + APPVEYOR + APPVEYOR_* + CI + CODECOV_* + TOXENV + TRAVIS + TRAVIS_* +deps = + coverage + codecov +commands = + coverage xml + codecov -X gcov -f coverage.xml diff --git a/userpath/__init__.py b/userpath/__init__.py index c1b235c..2c57aa6 100644 --- a/userpath/__init__.py +++ b/userpath/__init__.py @@ -1,5 +1,4 @@ -from .core import ( - prepend, append, in_current_path, in_new_path, need_shell_restart, normpath -) +from .core import append, in_new_path, need_shell_restart, prepend +from .utils import in_current_path __version__ = '1.1.0' diff --git a/userpath/cli.py b/userpath/cli.py index 819bb65..aa9eb3c 100644 --- a/userpath/cli.py +++ b/userpath/cli.py @@ -3,11 +3,10 @@ import click import userpath as up +from userpath.shells import DEFAULT_SHELLS, SHELLS -CONTEXT_SETTINGS = { - 'help_option_names': ['-h', '--help'], -} +CONTEXT_SETTINGS = {'help_option_names': ['-h', '--help']} def echo_success(text, nl=True): @@ -28,12 +27,31 @@ def userpath(): pass -@userpath.command(context_settings=CONTEXT_SETTINGS, - short_help='Prepends to the user PATH') +@userpath.command(context_settings=CONTEXT_SETTINGS, short_help='Prepends to the user PATH') @click.argument('locations', required=True, nargs=-1) -@click.option('-f', '--force', is_flag=True, - help='Update PATH even if it appears to be correct.') -def prepend(locations, force): +@click.option( + '-s', + '--shell', + 'shells', + multiple=True, + type=click.Choice(sorted(SHELLS)), + help=( + 'The shell in which PATH will be modified. This can be selected multiple times and has no ' + 'effect on Windows. The default shells are: {}'.format(', '.join(sorted(DEFAULT_SHELLS))) + ), +) +@click.option( + '-a', + '--all-shells', + is_flag=True, + help=( + 'Update PATH of all supported shells. This has no effect on Windows as environment settings are already global.' + ), +) +@click.option('--home', help='Explicitly set the home directory.') +@click.option('-f', '--force', is_flag=True, help='Update PATH even if it appears to be correct.') +@click.option('-q', '--quiet', is_flag=True, help='Suppress output for successful invocations.') +def prepend(locations, shells, all_shells, home, force, quiet): """Prepends to the user PATH. The shell must be restarted for the update to take effect. """ @@ -46,7 +64,7 @@ def prepend(locations, force): 'the -f/--force flag.'.format(location) )) sys.exit(2) - elif up.in_new_path(location): + elif up.in_new_path(location, shells=shells, all_shells=all_shells, home=home): echo_warning(( 'The directory `{}` is already in PATH, pending a shell ' 'restart! If you are sure you want to proceed, try again ' @@ -54,19 +72,41 @@ def prepend(locations, force): )) sys.exit(2) - if up.prepend(locations): - echo_success('Success!') - else: - echo_failure('An unexpected failure seems to have occurred.') + try: + up.prepend(locations, shells=shells, all_shells=all_shells, home=home, check=True) + except Exception as e: + echo_failure(str(e)) sys.exit(1) + else: + if not quiet: + echo_success('Success!') -@userpath.command(context_settings=CONTEXT_SETTINGS, - short_help='Appends to the user PATH') +@userpath.command(context_settings=CONTEXT_SETTINGS, short_help='Appends to the user PATH') @click.argument('locations', required=True, nargs=-1) -@click.option('-f', '--force', is_flag=True, - help='Update PATH even if it appears to be correct.') -def append(locations, force): +@click.option( + '-s', + '--shell', + 'shells', + multiple=True, + type=click.Choice(sorted(SHELLS)), + help=( + 'The shell in which PATH will be modified. This can be selected multiple times and has no ' + 'effect on Windows. The default shells are: {}'.format(', '.join(sorted(DEFAULT_SHELLS))) + ), +) +@click.option( + '-a', + '--all-shells', + is_flag=True, + help=( + 'Update PATH of all supported shells. This has no effect on Windows as environment settings are already global.' + ), +) +@click.option('--home', help='Explicitly set the home directory.') +@click.option('-f', '--force', is_flag=True, help='Update PATH even if it appears to be correct.') +@click.option('-q', '--quiet', is_flag=True, help='Suppress output for successful invocations.') +def append(locations, shells, all_shells, home, force, quiet): """Appends to the user PATH. The shell must be restarted for the update to take effect. """ @@ -79,7 +119,7 @@ def append(locations, force): 'the -f/--force flag.'.format(location) )) sys.exit(2) - elif up.in_new_path(location): + elif up.in_new_path(location, shells=shells, all_shells=all_shells, home=home): echo_warning(( 'The directory `{}` is already in PATH, pending a shell ' 'restart! If you are sure you want to proceed, try again ' @@ -87,30 +127,48 @@ def append(locations, force): )) sys.exit(2) - if up.append(locations): - echo_success('Success!') - else: - echo_failure('An unexpected failure seems to have occurred.') + try: + up.append(locations, shells=shells, all_shells=all_shells, home=home, check=True) + except Exception as e: + echo_failure(str(e)) sys.exit(1) + else: + if not quiet: + echo_success('Success!') -@userpath.command(context_settings=CONTEXT_SETTINGS, - short_help='Checks if locations are in the user PATH') +@userpath.command(context_settings=CONTEXT_SETTINGS, short_help='Checks if locations are in the user PATH') @click.argument('locations', required=True, nargs=-1) -def verify(locations): +@click.option( + '-s', + '--shell', + 'shells', + multiple=True, + type=click.Choice(sorted(SHELLS)), + help=( + 'The shell in which PATH will be modified. This can be selected multiple times and has no ' + 'effect on Windows. The default shells are: {}'.format(', '.join(sorted(DEFAULT_SHELLS))) + ), +) +@click.option( + '-a', + '--all-shells', + is_flag=True, + help=( + 'Update PATH of all supported shells. This has no effect on Windows as environment settings are already global.' + ), +) +@click.option('--home', help='Explicitly set the home directory.') +@click.option('-q', '--quiet', is_flag=True, help='Suppress output for successful invocations.') +def verify(locations, shells, all_shells, home, quiet): """Checks if locations are in the user PATH.""" for location in locations: if up.in_current_path(location): - echo_success(( - 'The directory `{}` is in PATH!'.format(location) - )) - elif up.in_new_path(location): - echo_warning(( - 'The directory `{}` is in PATH, pending a shell restart!'.format(location) - )) + if not quiet: + echo_success('The directory `{}` is in PATH!'.format(location)) + elif up.in_new_path(location, shells=shells, all_shells=all_shells, home=home): + echo_warning('The directory `{}` is in PATH, pending a shell restart!'.format(location)) sys.exit(2) else: - echo_failure(( - 'The directory `{}` is not in PATH!'.format(location) - )) + echo_failure('The directory `{}` is not in PATH!'.format(location)) sys.exit(1) diff --git a/userpath/core.py b/userpath/core.py index cee8ff2..86dc3c6 100644 --- a/userpath/core.py +++ b/userpath/core.py @@ -1,134 +1,22 @@ -import os -import platform -import subprocess +from .interface import Interface +from .utils import in_current_path -ON_WINDOWS = os.name == 'nt' or platform.system() == 'Windows' +def prepend(location, app_name=None, shells=None, all_shells=False, home=None, check=False): + interface = Interface(shells=shells, all_shells=all_shells, home=home) + return interface.put(location, front=True, app_name=app_name, check=check) -def normpath(location): - return os.path.abspath(os.path.expanduser(location.strip(';:'))) +def append(location, app_name=None, shells=None, all_shells=False, home=None, check=False): + interface = Interface(shells=shells, all_shells=all_shells, home=home) + return interface.put(location, front=False, app_name=app_name, check=check) -def location_in_path(location, path): - return normpath(location) in ( - os.path.normpath(p) for p in path.split(os.pathsep) - ) +def in_new_path(location, shells=None, all_shells=False, home=None, check=False): + interface = Interface(shells=shells, all_shells=all_shells, home=home) + return interface.location_in_new_path(location, check=check) -if ON_WINDOWS: - def get_new_path(): - output = subprocess.check_output([ - 'powershell', '-Command', "& {[Environment]::GetEnvironmentVariable('PATH', 'User')}" - ], shell=True).decode().strip() - # We do this because the output may contain new lines. - return ''.join(output.splitlines()) - - def put(location, front=True, app_name=None): - location = normpath(location) - - # PowerShell will always be available on Windows 7 or later. - try: - old_path = get_new_path() - head, tail = (location, old_path) if front else (old_path, location) - new_path = '{}{}{}'.format(head, os.pathsep, tail) - - subprocess.check_call([ - 'powershell', - '-Command', - "& {{[Environment]::SetEnvironmentVariable('PATH', '{}', 'User')}}".format(new_path) - ], shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except subprocess.CalledProcessError: - try: - head, tail = (location, '%~a') if front else ('%~a', location) - new_path = '{}{}{}'.format(head, os.pathsep, tail) - - # https://superuser.com/a/601034/766960 - subprocess.check_call(( - 'for /f "skip=2 tokens=3*" %a in (\'reg query HKCU\Environment ' - '/v PATH\') do @if [%b]==[] ( @setx PATH "{new_path}" ) else ' - '( @setx PATH "{new_path} %~b" )'.format(new_path=new_path) - ), shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except subprocess.CalledProcessError: - return False - - new_path = get_new_path() - return all(location_in_path(l, new_path) for l in location.split(os.pathsep)) - -else: - from datetime import datetime - - INIT_FILES = { - os.path.expanduser('~/.profile'): 'PATH="{}"\n', - os.path.expanduser('~/.bashrc'): 'export PATH="{}"\n', - # macOS seems to need this. - os.path.expanduser('~/.bash_profile'): 'export PATH="{}"\n', - } - - def get_new_path(): - return subprocess.check_output(['bash', '--login', '-c', 'echo $PATH']).decode().strip() - - def put(location, front=True, app_name=None): - # This function is probably insufficient even though it works in - # most situations. Please improve this to succeed more broadly! - location = normpath(location) - - try: - head, tail = (location, '$PATH') if front else ('$PATH', location) - new_path = '{}{}{}'.format(head, os.pathsep, tail) - - for file in INIT_FILES: - if os.path.exists(file): - with open(file, 'r') as f: - lines = f.readlines() - else: - lines = [] - - lines.extend([ - '# Created by `{}` on {}\n'.format( - app_name or 'userpath', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') - ), - INIT_FILES[file].format(new_path) - ]) - with open(file, 'w') as f: - f.writelines(lines) - except (OSError, PermissionError): - return False - - new_path = get_new_path() - return all(location_in_path(l, new_path) for l in location.split(os.pathsep)) - - -def prepend(location, app_name=None): - if isinstance(location, list) or isinstance(location, tuple): - location = os.pathsep.join(normpath(l) for l in location) - return put(location, front=True, app_name=app_name) - - -def append(location, app_name=None): - if isinstance(location, list) or isinstance(location, tuple): - location = os.pathsep.join(normpath(l) for l in location) - return put(location, front=False, app_name=app_name) - - -def in_current_path(location): - current_path = os.environ.get('PATH', '') - if isinstance(location, list) or isinstance(location, tuple): - return all(location_in_path(l, current_path) for l in location) - else: - return location_in_path(location, current_path) - - -def in_new_path(location): - new_path = get_new_path() - if isinstance(location, list) or isinstance(location, tuple): - return all(location_in_path(l, new_path) for l in location) - else: - return location_in_path(location, new_path) - - -def need_shell_restart(location): - if isinstance(location, list) or isinstance(location, tuple): - return any(not in_current_path(l) and in_new_path(l) for l in location) - else: - return not in_current_path(location) and in_new_path(location) +def need_shell_restart(location, shells=None, all_shells=False, home=None): + interface = Interface(shells=shells, all_shells=all_shells, home=home) + return not in_current_path(location) and interface.location_in_new_path(location) diff --git a/userpath/interface.py b/userpath/interface.py new file mode 100644 index 0000000..61ad26d --- /dev/null +++ b/userpath/interface.py @@ -0,0 +1,165 @@ +import os +import platform +import subprocess +from datetime import datetime +from io import open + +from .shells import DEFAULT_SHELLS, SHELLS +from .utils import ensure_parent_dir_exists, get_flat_output, get_parent_process_name, location_in_path, normpath + + +class WindowsInterface: + def __init__(self, **kwargs): + pass + + def location_in_new_path(self, location, check=False): + locations = normpath(location).split(os.pathsep) + show_path_command = ['powershell', '-Command', "& {[Environment]::GetEnvironmentVariable('PATH', 'User')}"] + new_path = get_flat_output(show_path_command, sep='', shell=True) + + for location in locations: + if not location_in_path(location, new_path): + if check: + raise Exception( + 'Unable to find `{}` in the output of `{}`:\n{}'.format(location, show_path_command, new_path) + ) + else: + return False + else: + return True + + def put(self, location, front=True, check=False, **kwargs): + location = normpath(location) + + # PowerShell should always be available on Windows 7 or later. + try: + old_path = os.environ.get('PATH', '') + head, tail = (location, old_path) if front else (old_path, location) + new_path = '{}{}{}'.format(head, os.pathsep, tail) + + subprocess.check_output( + [ + 'powershell', + '-Command', + "& {{[Environment]::SetEnvironmentVariable('PATH', '{}', 'User')}}".format(new_path), + ], + shell=True, + ) + except subprocess.CalledProcessError: # no cov + try: + head, tail = (location, '%~a') if front else ('%~a', location) + new_path = '{}{}{}'.format(head, os.pathsep, tail) + + # https://superuser.com/a/601034/766960 + subprocess.check_output( + ( + 'for /f "skip=2 tokens=3*" %a in (\'reg query HKCU\\Environment ' + '/v PATH\') do @if [%b]==[] ( @setx PATH "{new_path}" ) else ' + '( @setx PATH "{new_path} %~b" )'.format(new_path=new_path) + ), + shell=True, + ) + except subprocess.CalledProcessError: + return False + + return self.location_in_new_path(location, check=check) + + +class UnixInterface: + def __init__(self, shells=None, all_shells=False, home=None): + if shells: + all_shells = False + else: + if all_shells: + shells = sorted(SHELLS) + else: + shells = [self.detect_shell()] + + shells = [os.path.basename(shell).lower() for shell in shells if shell] + shells = [shell for shell in shells if shell in SHELLS] + + if not shells: + shells = DEFAULT_SHELLS + + # De-dup and retain order + deduplicated_shells = set() + selected_shells = [] + for shell in shells: + if shell not in deduplicated_shells: + deduplicated_shells.add(shell) + selected_shells.append(shell) + + self.shells = [SHELLS[shell](home) for shell in selected_shells] + self.shells_to_verify = [SHELLS[shell](home) for shell in DEFAULT_SHELLS] if all_shells else self.shells + + @classmethod + def detect_shell(cls): + # First, try to see what spawned this process + shell = get_parent_process_name().lower() + if shell in SHELLS: + return shell + + # Then, search for environment variables that are known to be set by certain shells + # NOTE: This likely does not work when not directly in the shell + if 'BASH_VERSION' in os.environ: + return 'bash' + + # Finally, try global environment + shell = os.path.basename(os.environ.get('SHELL', '')).lower() + if shell in SHELLS: + return shell + + def location_in_new_path(self, location, check=False): + locations = normpath(location).split(os.pathsep) + + for shell in self.shells_to_verify: + for show_path_command in shell.show_path_commands(): + new_path = get_flat_output(show_path_command) + for location in locations: + if not location_in_path(location, new_path): + if check: + raise Exception( + 'Unable to find `{}` in the output of `{}`:\n{}'.format( + location, show_path_command, new_path + ) + ) + else: + return False + else: + return True + + def put(self, location, front=True, app_name=None, check=False): + location = normpath(location) + app_name = app_name or 'userpath' + + for shell in self.shells: + for file, contents in shell.config(location, front=front).items(): + try: + ensure_parent_dir_exists(file) + + if os.path.exists(file): + with open(file, 'r', encoding='utf-8') as f: + lines = f.readlines() + else: + lines = [] + + lines.append( + u'\n{} Created by `{}` on {}\n'.format( + shell.comment_starter, app_name, datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + ) + ) + lines.append(u'{}\n'.format(contents)) + + with open(file, 'w', encoding='utf-8') as f: + f.writelines(lines) + except Exception: + continue + + return self.location_in_new_path(location, check=check) + + +__default_interface = WindowsInterface if os.name == 'nt' or platform.system() == 'Windows' else UnixInterface + + +class Interface(__default_interface): + pass diff --git a/userpath/shells.py b/userpath/shells.py new file mode 100644 index 0000000..939931d --- /dev/null +++ b/userpath/shells.py @@ -0,0 +1,108 @@ +from os import path, pathsep + +import distro + +DEFAULT_SHELLS = ('bash', 'sh') + + +class Shell(object): + comment_starter = '#' + + def __init__(self, home=None): + self.home = home or path.expanduser('~') + + +class Sh(Shell): + def config(self, location, front=True): + head, tail = (location, '$PATH') if front else ('$PATH', location) + new_path = '{}{}{}'.format(head, pathsep, tail) + + return {path.join(self.home, '.profile'): 'PATH="{}"'.format(new_path)} + + @classmethod + def show_path_commands(cls): + # TODO: Find out what file influences non-login shells. The issue may simply be our Docker setup. + return [['sh', '-i', '-l', '-c', 'echo $PATH']] + + +class Bash(Shell): + def config(self, location, front=True): + head, tail = (location, '$PATH') if front else ('$PATH', location) + new_path = '{}{}{}'.format(head, pathsep, tail) + contents = 'export PATH="{}"'.format(new_path) + + configs = {path.join(self.home, '.bashrc'): contents} + + # https://github.com/ofek/userpath/issues/3#issuecomment-492491977 + if distro.id() == 'ubuntu': + login_config = path.join(self.home, '.profile') + else: + # NOTE: If it is decided in future that we want to make a distinction between + # login and non-login shells, be aware that macOS will still need this since + # Terminal.app runs a login shell by default for each new terminal window. + login_config = path.join(self.home, '.bash_profile') + + configs[login_config] = contents + + return configs + + @classmethod + def show_path_commands(cls): + return [['bash', '-i', '-c', 'echo $PATH'], ['bash', '-i', '-l', '-c', 'echo $PATH']] + + +class Fish(Shell): + def config(self, location, front=True): + location = ' '.join(location.split(pathsep)) + head, tail = (location, '$PATH') if front else ('$PATH', location) + + # https://github.com/fish-shell/fish-shell/issues/527#issuecomment-12436286 + contents = 'set PATH {} {}'.format(head, tail) + + return {path.join(self.home, '.config', 'fish', 'config.fish'): contents} + + @classmethod + def show_path_commands(cls): + return [ + ['fish', '-i', '-c', 'for p in $PATH; echo "$p"; end'], + ['fish', '-i', '-l', '-c', 'for p in $PATH; echo "$p"; end'], + ] + + +class Xonsh(Shell): + def config(self, location, front=True): + locations = location.split(pathsep) + + if front: + contents = '\n'.join('$PATH.insert(0, {!r})'.format(location) for location in reversed(locations)) + else: + contents = '\n'.join('$PATH.append({!r})'.format(location) for location in locations) + + return {path.join(self.home, '.xonshrc'): contents} + + @classmethod + def show_path_commands(cls): + command = "print('{}'.join($PATH))".format(pathsep) + return [['xonsh', '-i', '-c', command], ['xonsh', '-i', '--login', '-c', command]] + + +class Zsh(Shell): + def config(self, location, front=True): + head, tail = (location, '$PATH') if front else ('$PATH', location) + new_path = '{}{}{}'.format(head, pathsep, tail) + contents = 'export PATH="{}"'.format(new_path) + + return {path.join(self.home, '.zshrc'): contents, path.join(self.home, '.zprofile'): contents} + + @classmethod + def show_path_commands(cls): + return [['zsh', '-i', '-c', 'echo $PATH'], ['zsh', '-i', '-l', '-c', 'echo $PATH']] + + +SHELLS = { + 'bash': Bash, + 'fish': Fish, + 'sh': Sh, + 'xonsh': Xonsh, + 'zsh': Zsh, +} diff --git a/userpath/utils.py b/userpath/utils.py new file mode 100644 index 0000000..3683b9f --- /dev/null +++ b/userpath/utils.py @@ -0,0 +1,58 @@ +import os +import subprocess + +try: + import psutil +except Exception: + pass + + +def normpath(location): + if isinstance(location, (list, tuple)): + return os.pathsep.join(normpath(l) for l in location) + + return os.path.abspath(os.path.expanduser(location.strip(';:'))) + + +def location_in_path(location, path): + return normpath(location) in (os.path.normpath(p) for p in path.split(os.pathsep)) + + +def in_current_path(location): + return location_in_path(location, os.environ.get('PATH', '')) + + +def ensure_parent_dir_exists(path): + parent_dir = os.path.dirname(os.path.abspath(path)) + if not os.path.isdir(parent_dir): + os.makedirs(parent_dir) + + +def get_flat_output(command, sep=os.pathsep, **kwargs): + process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) + output = process.communicate()[0].decode('utf-8').strip() + + # We do this because the output may contain new lines. + lines = [line.strip() for line in output.splitlines()] + return sep.join(line for line in lines if line) + + +def get_parent_process_name(): + # We want this to never throw an exception + try: + if psutil: + try: + pid = os.getpid() + process = psutil.Process(pid) + ppid = process.ppid() + pprocess = psutil.Process(ppid) + return pprocess.name() + except Exception: + pass + + ppid = os.getppid() + return subprocess.check_output(['ps', '-o', 'cmd=', str(ppid)]).decode('utf-8').strip() + except Exception: + pass + + return ''