pax_global_header00006660000000000000000000000064141755624260014526gustar00rootroot0000000000000052 comment=0ca7f74aa316bdd683338ed760f966941d5e48fa userpath-1.8.0/000077500000000000000000000000001417556242600133675ustar00rootroot00000000000000userpath-1.8.0/.codecov.yml000066400000000000000000000002031417556242600156050ustar00rootroot00000000000000comment: false coverage: status: patch: default: target: '80' project: default: target: '80' userpath-1.8.0/.coveragerc000066400000000000000000000006361417556242600155150ustar00rootroot00000000000000[run] data_file = tests/coverage/.coverage branch = True parallel = True source = userpath tests 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 = no cov no qa noqa pragma: no cover if __name__ == .__main__.: userpath-1.8.0/.gitattributes000066400000000000000000000001021417556242600162530ustar00rootroot00000000000000# Auto detect text files and perform LF normalization * text=auto userpath-1.8.0/.github/000077500000000000000000000000001417556242600147275ustar00rootroot00000000000000userpath-1.8.0/.github/FUNDING.yml000066400000000000000000000001161417556242600165420ustar00rootroot00000000000000github: - ofek custom: - https://ofek.dev/donate/ - https://paypal.me/ofeklev userpath-1.8.0/.github/workflows/000077500000000000000000000000001417556242600167645ustar00rootroot00000000000000userpath-1.8.0/.github/workflows/build.yml000066400000000000000000000015741417556242600206150ustar00rootroot00000000000000name: build on: push: tags: - v* concurrency: group: build-${{ github.head_ref }} jobs: build: name: Build wheels and source distribution runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install build dependencies run: python -m pip install --upgrade build - name: Build run: python -m build - uses: actions/upload-artifact@v2 with: name: artifacts path: dist/* if-no-files-found: error publish: name: Publish release needs: - build runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v2 with: name: artifacts path: dist - name: Push build artifacts to PyPI uses: pypa/gh-action-pypi-publish@v1.4.2 with: skip_existing: true user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} userpath-1.8.0/.github/workflows/test.yml000066400000000000000000000015521417556242600204710ustar00rootroot00000000000000name: test on: push: branches: - master pull_request: branches: - master concurrency: group: test-${{ github.head_ref }} cancel-in-progress: true jobs: run: name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] python-version: ['3.7'] env: PYTHON_VERSION: ${{ matrix.python-version }} steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install test dependencies run: pip install --upgrade tox - name: Run tests run: tox -e ${{ matrix.python-version }},coverage userpath-1.8.0/.gitignore000066400000000000000000000001541417556242600153570ustar00rootroot00000000000000*.log *.pyc .cache/ .coverage .idea/ .tox/ .vscode/ userpath.egg-info/ build/ dist/ docs/build/ wheelhouse/ userpath-1.8.0/HISTORY.rst000066400000000000000000000034031417556242600152620ustar00rootroot00000000000000History ------- master ^^^^^^ 1.8.0 ^^^^^ - Broadcast WM_SETTINGCHANGE on Windows - Drop Python 2.7 & 3.6 1.7.0 ^^^^^ - Fix path normalization to be aware of case-insensitive platforms and symlinks. 1.6.0 ^^^^^ - Use locale's encoding when handling output from subprocesses 1.5.0 ^^^^^ - Modify bash start-up files based on their existence - Remove ``distro`` dependency 1.4.2 ^^^^^ - Fix fallback mechanism for detecting the name of the parent process 1.4.1 ^^^^^ - Fix PATH registry key type on Windows 1.4.0 ^^^^^ - Fix duplicating system paths on Windows - Prevent adding paths multiple times on macOS/Linux - Send CLI errors to stderr instead of stdout 1.3.0 (2019-10-20) ^^^^^^^^^^^^^^^^^^ - Only require the dependency ``distro`` on Linux - Ship tests with source distributions - Expanded ``HISTORY.rst`` 1.2.0 (2019-07-14) ^^^^^^^^^^^^^^^^^^ - Added support for shell auto-detection and selection 1.1.0 (2018-05-16) ^^^^^^^^^^^^^^^^^^ - First public stable release 1.0.0 (2018-05-16) ^^^^^^^^^^^^^^^^^^ - Renamed PyPI package from `adduserpath` to `userpath`. Installed package in site-packages remains named `userpath` - Converted files to Unix end of lines 0.4.0 (2017-09-28) ^^^^^^^^^^^^^^^^^^ - Expand `~` and `~user` constructions if `$HOME` or `user` is known 0.3.0 (2017-09-26) ^^^^^^^^^^^^^^^^^^ - Renamed argument path to locations - Support operations on multiple locations - Improved image 0.2.0 (2017-09-21) ^^^^^^^^^^^^^^^^^^ - First release on PyPI, as package named `adduserpath` 0.1.5 (2017-09-20) ^^^^^^^^^^^^^^^^^^ - Improved setup.py 0.1.0 (2017-09-20) ^^^^^^^^^^^^^^^^^^ - First release on GitHub, `setup.py` package named `adduserpath` 0.0.1 (2017-09-20) ^^^^^^^^^^^^^^^^^^ - first commit, site-packages package named `userpath` userpath-1.8.0/LICENSE.txt000066400000000000000000000021001417556242600152030ustar00rootroot00000000000000MIT License Copyright (c) 2017-present Ofek Lev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. userpath-1.8.0/README.md000066400000000000000000000034471417556242600146560ustar00rootroot00000000000000# userpath | | | | --- | --- | | CI/CD | [![CI - Test](https://github.com/ofek/userpath/actions/workflows/test.yml/badge.svg)](https://github.com/ofek/userpath/actions/workflows/test.yml) [![CD - Build](https://github.com/ofek/userpath/actions/workflows/build.yml/badge.svg)](https://github.com/ofek/userpath/actions/workflows/build.yml) | | Package | [![PyPI - Version](https://img.shields.io/pypi/v/userpath.svg?logo=pypi&label=PyPI&logoColor=gold)](https://pypi.org/project/userpath/) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/userpath.svg?logo=python&label=Python&logoColor=gold)](https://pypi.org/project/userpath/) | | Meta | [![License - MIT](https://img.shields.io/badge/license-MIT-9400d3.svg)](https://spdx.org/licenses/) [![GitHub Sponsors](https://img.shields.io/github/sponsors/ofek?logo=GitHub%20Sponsors&style=social)](https://github.com/sponsors/ofek) | ----- This is a tool for modifying a user's `PATH`. **Table of Contents** - [Installation](#installation) - [CLI](#cli) - [API](#api) - [License](#license) ## Installation ```console pip install userpath ``` ## CLI ```console $ userpath -h Usage: userpath [OPTIONS] COMMAND [ARGS]... Options: --version Show the version and exit. -h, --help Show this message and exit. Commands: append Appends to the user PATH prepend Prepends to the user PATH verify Checks if locations are in the user PATH ``` ## API ```pycon >>> import userpath >>> location = r'C:\Users\Ofek\Desktop\test' >>> >>> userpath.in_current_path(location) False >>> userpath.in_new_path(location) False >>> userpath.append(location) True >>> userpath.in_new_path(location) True >>> userpath.need_shell_restart(location) True ``` ## License `userpath` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. userpath-1.8.0/pyproject.toml000066400000000000000000000023641417556242600163100ustar00rootroot00000000000000[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "userpath" description = 'Cross-platform tool for adding locations to the user PATH' readme = "README.md" license = "MIT" requires-python = ">=3.7" keywords = [ "path", "user path", ] authors = [ { name = "Ofek Lev", email = "oss@ofek.dev" }, ] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ "click", ] dynamic = ["version"] [project.urls] Funding = "https://github.com/sponsors/ofek" History = "https://github.com/ofek/userpath/blob/master/HISTORY.rst" Issues = "https://github.com/ofek/userpath/issues" Source = "https://github.com/ofek/userpath" [project.scripts] userpath = "userpath.cli:userpath" [tool.hatch.version] path = "userpath/__init__.py" [tool.hatch.build.targets.sdist] [tool.hatch.build.targets.wheel] userpath-1.8.0/requirements-dev.txt000066400000000000000000000000201417556242600174170ustar00rootroot00000000000000coverage pytest userpath-1.8.0/tests/000077500000000000000000000000001417556242600145315ustar00rootroot00000000000000userpath-1.8.0/tests/__init__.py000066400000000000000000000000001417556242600166300ustar00rootroot00000000000000userpath-1.8.0/tests/conftest.py000066400000000000000000000047261417556242600167410ustar00rootroot00000000000000import os import platform 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 or platform.system() == 'Windows': 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) try: os.environ['SHELL'] = shell_name os.environ['DOCKERFILE'] = dockerfile os.environ['PYTHON_VERSION'] = os.environ['TOX_ENV_NAME'] 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) userpath-1.8.0/tests/coverage/000077500000000000000000000000001417556242600163245ustar00rootroot00000000000000userpath-1.8.0/tests/coverage/.gitignore000066400000000000000000000001041417556242600203070ustar00rootroot00000000000000# Ignore this directory used for coverage aggregation * !.gitignore userpath-1.8.0/tests/docker/000077500000000000000000000000001417556242600160005ustar00rootroot00000000000000userpath-1.8.0/tests/docker/debian000066400000000000000000000003351417556242600171460ustar00rootroot00000000000000ARG PYTHON_VERSION FROM python:${PYTHON_VERSION} 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"] userpath-1.8.0/tests/docker/docker-compose.yaml000066400000000000000000000004311417556242600215740ustar00rootroot00000000000000version: '3' services: userpath: container_name: ${SHELL}-${DOCKERFILE} build: context: . dockerfile: ./${DOCKERFILE} args: PYTHON_VERSION: ${PYTHON_VERSION} environment: - SHELL=${SHELL} volumes: - ./../../:/home/userpath userpath-1.8.0/tests/docker/requirements.txt000066400000000000000000000001511417556242600212610ustar00rootroot00000000000000# Deps click distro # Test deps coverage pytest # xonsh shell, if we can xonsh; python_version > '3.0' userpath-1.8.0/tests/test_bash.py000066400000000000000000000060141417556242600170600ustar00rootroot00000000000000import 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_prepend_twice(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) assert userpath.prepend(location, check=True) 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') userpath-1.8.0/tests/test_fish.py000066400000000000000000000046551417556242600171050ustar00rootroot00000000000000import 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') userpath-1.8.0/tests/test_sh.py000066400000000000000000000046511417556242600165620ustar00rootroot00000000000000import 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') userpath-1.8.0/tests/test_windows.py000066400000000000000000000023241417556242600176350ustar00rootroot00000000000000import 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) userpath-1.8.0/tests/test_xonsh.py000066400000000000000000000046571417556242600173150ustar00rootroot00000000000000import 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') userpath-1.8.0/tests/test_zsh.py000066400000000000000000000046531417556242600167560ustar00rootroot00000000000000import 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') userpath-1.8.0/tests/utils.py000066400000000000000000000004201417556242600162370ustar00rootroot00000000000000import 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() userpath-1.8.0/tox.ini000066400000000000000000000010341417556242600147000ustar00rootroot00000000000000[tox] skip_missing_interpreters = true envlist = 3.7 coverage [testenv] usedevelop = true passenv = APPVEYOR deps = -rrequirements-dev.txt commands = coverage run -m pytest -v {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 userpath-1.8.0/userpath/000077500000000000000000000000001417556242600152225ustar00rootroot00000000000000userpath-1.8.0/userpath/__init__.py000066400000000000000000000001751417556242600173360ustar00rootroot00000000000000from .core import append, in_new_path, need_shell_restart, prepend from .utils import in_current_path __version__ = '1.8.0' userpath-1.8.0/userpath/__main__.py000066400000000000000000000000721417556242600173130ustar00rootroot00000000000000import sys from .cli import userpath sys.exit(userpath()) userpath-1.8.0/userpath/cli.py000066400000000000000000000141571417556242600163530ustar00rootroot00000000000000import sys import click import userpath as up from userpath.shells import DEFAULT_SHELLS, SHELLS CONTEXT_SETTINGS = {'help_option_names': ['-h', '--help']} def echo_success(text, nl=True): click.secho(text, fg='cyan', bold=True, nl=nl) def echo_failure(text, nl=True): click.secho(text, fg='red', bold=True, nl=nl, err=True) def echo_warning(text, nl=True): click.secho(text, fg='yellow', bold=True, nl=nl) @click.group(context_settings=CONTEXT_SETTINGS) @click.version_option() def userpath(): pass @userpath.command(context_settings=CONTEXT_SETTINGS, short_help='Prepends to the user PATH') @click.argument('locations', required=True, nargs=-1) @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. """ if not force: for location in locations: if up.in_current_path(location): echo_warning(( 'The directory `{}` is already in PATH! If you ' 'are sure you want to proceed, try again with ' 'the -f/--force flag.'.format(location) )) sys.exit(2) 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 ' 'with the -f/--force flag.'.format(location) )) sys.exit(2) 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') @click.argument('locations', required=True, nargs=-1) @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. """ if not force: for location in locations: if up.in_current_path(location): echo_warning(( 'The directory `{}` is already in PATH! If you ' 'are sure you want to proceed, try again with ' 'the -f/--force flag.'.format(location) )) sys.exit(2) 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 ' 'with the -f/--force flag.'.format(location) )) sys.exit(2) 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') @click.argument('locations', required=True, nargs=-1) @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): 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)) sys.exit(1) userpath-1.8.0/userpath/core.py000066400000000000000000000020051417556242600165210ustar00rootroot00000000000000from .interface import Interface from .utils import in_current_path 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 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 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) 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) userpath-1.8.0/userpath/interface.py000066400000000000000000000133321417556242600175360ustar00rootroot00000000000000import os import platform 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 try: import winreg except ImportError: try: import _winreg as winreg except ImportError: winreg = None class WindowsInterface: def __init__(self, **kwargs): pass @staticmethod def _get_new_path(): with winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_READ) as key: return winreg.QueryValueEx(key, 'PATH')[0] def location_in_new_path(self, location, check=False): locations = normpath(location).split(os.pathsep) new_path = self._get_new_path() for location in locations: if not location_in_path(location, new_path): if check: raise Exception('Unable to find `{}` in:\n{}'.format(location, new_path)) else: return False else: return True def put(self, location, front=True, check=False, **kwargs): import ctypes location = normpath(location) head, tail = (location, self._get_new_path()) if front else (self._get_new_path(), location) new_path = '{}{}{}'.format(head, os.pathsep, tail) with winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_WRITE) as key: winreg.SetValueEx(key, 'PATH', 0, winreg.REG_EXPAND_SZ, new_path) # https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagetimeoutw # https://docs.microsoft.com/en-us/windows/win32/winmsg/wm-settingchange ctypes.windll.user32.SendMessageTimeoutW( 0xFFFF, # HWND_BROADCAST 0x1A, # WM_SETTINGCHANGE 0, # must be NULL 'Environment', 0x0002, # SMTO_ABORTIFHUNG 5000, # milliseconds ctypes.wintypes.DWORD(), ) 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 = [] if any(contents in line for line in lines): continue 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 userpath-1.8.0/userpath/shells.py000066400000000000000000000072551417556242600170770ustar00rootroot00000000000000from os import environ, path, pathsep 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 profile_path = path.join(self.home, '.profile') bash_profile_path = path.join(self.home, '.bash_profile') if path.exists(profile_path) and not path.exists(bash_profile_path): login_config = profile_path 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 = bash_profile_path 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) zdotdir = environ.get('ZDOTDIR', self.home) return {path.join(zdotdir, '.zshrc'): contents, path.join(zdotdir, '.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, } userpath-1.8.0/userpath/utils.py000066400000000000000000000033251417556242600167370ustar00rootroot00000000000000import locale import os import subprocess try: import psutil except Exception: psutil = None def normpath(location): if isinstance(location, (list, tuple)): return os.pathsep.join(normpath(l) for l in location) return os.path.normcase(os.path.realpath(os.path.expanduser(location.strip(';:')))) def location_in_path(location, path): return normpath(location) in (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(locale.getpreferredencoding(False)).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() process_name = subprocess.check_output(['ps', '-o', 'args=', str(ppid)]).decode('utf-8') return process_name.strip().lstrip("-") except Exception: pass return ''