From c6ced77f94a7f412f88913400c48e93b535c6732 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 13 May 2019 00:55:55 -0400 Subject: [PATCH 01/19] Add shell detection --- userpath/__init__.py | 5 +- userpath/cli.py | 102 +++++++++++++++++++--------- userpath/core.py | 140 ++++---------------------------------- userpath/interface.py | 153 ++++++++++++++++++++++++++++++++++++++++++ userpath/shells.py | 94 ++++++++++++++++++++++++++ userpath/utils.py | 50 ++++++++++++++ 6 files changed, 385 insertions(+), 159 deletions(-) create mode 100644 userpath/interface.py create mode 100644 userpath/shells.py create mode 100644 userpath/utils.py 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..d092681 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,28 @@ 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 that is the standard behavior.', +) +@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.') +def prepend(locations, shells, all_shells, home, force): """Prepends to the user PATH. The shell must be restarted for the update to take effect. """ @@ -46,7 +61,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 +69,35 @@ def prepend(locations, force): )) sys.exit(2) - if up.prepend(locations): + if up.prepend(locations, shells=shells, all_shells=all_shells, home=home): echo_success('Success!') else: echo_failure('An unexpected failure seems to have occurred.') sys.exit(1) -@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 that is the standard behavior.', +) +@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.') +def append(locations, shells, all_shells, home, force): """Appends to the user PATH. The shell must be restarted for the update to take effect. """ @@ -79,7 +110,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 +118,41 @@ def append(locations, force): )) sys.exit(2) - if up.append(locations): + if up.append(locations, shells=shells, all_shells=all_shells, home=home): echo_success('Success!') else: echo_failure('An unexpected failure seems to have occurred.') sys.exit(1) -@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 that is the standard behavior.', +) +@click.option('--home', help='Explicitly set the home directory.') +def verify(locations, shells, all_shells, home): """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) - )) + 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..58f5df6 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): + interface = Interface(shells=shells, all_shells=all_shells, home=home) + return interface.put(location, front=True, app_name=app_name) -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): + interface = Interface(shells=shells, all_shells=all_shells, home=home) + return interface.put(location, front=False, app_name=app_name) -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): + interface = Interface(shells=shells, all_shells=all_shells, home=home) + return interface.location_in_new_path(location) -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..b94b836 --- /dev/null +++ b/userpath/interface.py @@ -0,0 +1,153 @@ +import os +import platform +import subprocess +from datetime import datetime +from io import open + +from .shells import DEFAULT_SHELLS, SHELLS +from .utils import get_flat_output, get_parent_process_name, location_in_path, normpath + + +class WindowsInterface: + def __init__(self, **kwargs): + pass + + @classmethod + def get_windows_new_path(cls): + return get_flat_output( + [ + 'powershell', '-Command', "& {[Environment]::GetEnvironmentVariable('PATH', 'User')}" + ], + sep='', + shell=True, + ) + + def location_in_new_path(self, location): + location = normpath(location) + new_path = self.get_windows_new_path() + return all(location_in_path(l, new_path) for l in location.split(os.pathsep)) + + def put(self, location, front=True, **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: + 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) + + +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): + locations = normpath(location).split(os.pathsep) + + for shell in self.shells_to_verify: + new_path = get_flat_output(shell.show_path_command()) + if not all(location_in_path(l, new_path) for l in locations): + return False + else: + return True + + def put(self, location, front=True, app_name=None): + location = normpath(location) + app_name = app_name or 'userpath' + + for shell in self.shells: + for file, contents in shell.config(location, front=front): + try: + if os.path.exists(file): + with open(file, 'r', encoding='utf-8') as f: + lines = f.readlines() + else: + lines = [] + + lines.append( + '\n{} Created by `{}` on {}\n'.format( + shell.comment_starter, app_name, datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + ) + ) + lines.append('{}\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) + + +__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..2e97dcf --- /dev/null +++ b/userpath/shells.py @@ -0,0 +1,94 @@ +from os import 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_command(cls): + return ['sh', '-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) + + return { + path.join(self.home, '.bashrc'): contents, + # 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. + path.join(self.home, '.bash_profile'): contents, + } + + @classmethod + def show_path_command(cls): + return ['bash', '--login', '-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_command(cls): + return ['fish', '--login', '-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_command(cls): + return ['xonsh', '--login', '-c', "print('{}'.join($PATH))".format(pathsep)] + + +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_command(cls): + return ['zsh', '--login', '-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..46d01e0 --- /dev/null +++ b/userpath/utils.py @@ -0,0 +1,50 @@ +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 get_flat_output(command, sep=os.pathsep, **kwargs): + output = subprocess.check_output(command, **kwargs).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 From 959f9da4969ffdb176e66ba3ae8e2467ef06fe85 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 13 May 2019 01:01:27 -0400 Subject: [PATCH 02/19] fix --- .travis.yml | 4 ++++ userpath/utils.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/.travis.yml b/.travis.yml index e21b6bd..c2f18bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,9 @@ language: python +branches: + only: + - master + matrix: include: - python: 2.7 diff --git a/userpath/utils.py b/userpath/utils.py index 46d01e0..43fcebc 100644 --- a/userpath/utils.py +++ b/userpath/utils.py @@ -48,3 +48,5 @@ def get_parent_process_name(): return subprocess.check_output(['ps', '-o', 'cmd=', str(ppid)]).decode('utf-8').strip() except Exception: pass + + return '' From 07b221a2dd96d1ff52fc42f2103ea81f4f297a65 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 13 May 2019 01:05:02 -0400 Subject: [PATCH 03/19] oop --- .appveyor.yml | 5 +++++ userpath/interface.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.appveyor.yml b/.appveyor.yml index 89e5b74..15510fd 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -1,3 +1,8 @@ +skip_branch_with_pr: true +branches: + only: + - master + environment: matrix: - PYTHON: "C:\\Python27-x64" diff --git a/userpath/interface.py b/userpath/interface.py index b94b836..c60caab 100644 --- a/userpath/interface.py +++ b/userpath/interface.py @@ -123,7 +123,7 @@ def put(self, location, front=True, app_name=None): app_name = app_name or 'userpath' for shell in self.shells: - for file, contents in shell.config(location, front=front): + for file, contents in shell.config(location, front=front).items(): try: if os.path.exists(file): with open(file, 'r', encoding='utf-8') as f: From efe4d443dd49e1a453bbdcaf03e13a1275d14973 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 13 May 2019 01:16:50 -0400 Subject: [PATCH 04/19] see --- tox.ini | 2 +- userpath/interface.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 079bca5..d2b3e33 100644 --- a/tox.ini +++ b/tox.ini @@ -13,4 +13,4 @@ deps = pytest commands = python setup.py --quiet clean develop - pytest + pytest -v diff --git a/userpath/interface.py b/userpath/interface.py index c60caab..ee573de 100644 --- a/userpath/interface.py +++ b/userpath/interface.py @@ -132,15 +132,15 @@ def put(self, location, front=True, app_name=None): lines = [] lines.append( - '\n{} Created by `{}` on {}\n'.format( + u'\n{} Created by `{}` on {}\n'.format( shell.comment_starter, app_name, datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') ) ) - lines.append('{}\n'.format(contents)) + lines.append(u'{}\n'.format(contents)) with open(file, 'w', encoding='utf-8') as f: f.writelines(lines) - except Exception: + except Exception as e: continue return self.location_in_new_path(location) From 3cc42f8c263780d18d76e34cb507274eb6f34a71 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 13 May 2019 02:51:14 -0400 Subject: [PATCH 05/19] ok --- userpath/interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userpath/interface.py b/userpath/interface.py index ee573de..e569b05 100644 --- a/userpath/interface.py +++ b/userpath/interface.py @@ -140,7 +140,7 @@ def put(self, location, front=True, app_name=None): with open(file, 'w', encoding='utf-8') as f: f.writelines(lines) - except Exception as e: + except Exception: continue return self.location_in_new_path(location) From c0eef5f3249d2ea751f9d2d2765d507e050d4bf8 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 16:29:36 -0400 Subject: [PATCH 06/19] refactor & add integration tests * o * closer * fish why * yay --- .gitignore | 1 + .travis.yml | 24 +++----- README.rst | 2 +- requirements-dev.txt | 2 + requirements.txt | 1 - setup.py | 11 ++-- tests/conftest.py | 78 +++++++++++++++++++++++++ tests/coverage/.gitignore | 3 + tests/docker/debian | 12 ++++ tests/docker/docker-compose.yaml | 15 +++++ tests/docker/requirements-dev.txt | 7 +++ tests/test_bash.py | 65 +++++++++++++++++++++ tests/test_fish.py | 65 +++++++++++++++++++++ tests/test_sh.py | 65 +++++++++++++++++++++ tests/{test_core.py => test_windows.py} | 20 ++++--- tests/test_xonsh.py | 65 +++++++++++++++++++++ tests/test_zsh.py | 65 +++++++++++++++++++++ tests/utils.py | 11 ++++ tox.ini | 15 ++--- userpath/core.py | 12 ++-- userpath/interface.py | 60 +++++++++++-------- userpath/shells.py | 46 ++++++++++----- userpath/utils.py | 10 +++- 23 files changed, 564 insertions(+), 91 deletions(-) create mode 100644 requirements-dev.txt delete mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/coverage/.gitignore create mode 100644 tests/docker/debian create mode 100644 tests/docker/docker-compose.yaml create mode 100644 tests/docker/requirements-dev.txt create mode 100644 tests/test_bash.py create mode 100644 tests/test_fish.py create mode 100644 tests/test_sh.py rename tests/{test_core.py => test_windows.py} (70%) create mode 100644 tests/test_xonsh.py create mode 100644 tests/test_zsh.py create mode 100644 tests/utils.py 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 c2f18bc..b217971 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,25 +1,19 @@ +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 diff --git a/README.rst b/README.rst index 40032b6..e03499e 100644 --- a/README.rst +++ b/README.rst @@ -38,7 +38,7 @@ 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. +Python 2.7/3.4+ and PyPy. .. code-block:: bash 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..a1cc53e 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,17 @@ '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' ], 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..094ee67 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,78 @@ +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, + '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..544e200 --- /dev/null +++ b/tests/docker/debian @@ -0,0 +1,12 @@ +ARG PYTHON_VERSION +FROM python:${PYTHON_VERSION}-stretch + +RUN apt-get update \ + && apt-get --no-install-recommends -y install fish zsh + +COPY requirements-dev.txt / +RUN pip install -r requirements-dev.txt + +RUN pip install xonsh + +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-dev.txt b/tests/docker/requirements-dev.txt new file mode 100644 index 0000000..9c38d8c --- /dev/null +++ b/tests/docker/requirements-dev.txt @@ -0,0 +1,7 @@ +# Deps +click +distro + +# Test deps +coverage +pytest 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_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_core.py b/tests/test_windows.py similarity index 70% rename from tests/test_core.py rename to tests/test_windows.py index 9c666e8..e3f7188 100644 --- a/tests/test_core.py +++ b/tests/test_windows.py @@ -1,11 +1,13 @@ -from base64 import urlsafe_b64encode -from os import urandom - +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 = urlsafe_b64encode(urandom(5)).decode() + location = get_random_path() assert not userpath.in_current_path(location) assert userpath.prepend(location) assert userpath.in_new_path(location) @@ -13,8 +15,8 @@ def test_prepend(): def test_prepend_multiple(): - location1 = urlsafe_b64encode(urandom(5)).decode() - location2 = urlsafe_b64encode(urandom(5)).decode() + location1 = get_random_path() + location2 = get_random_path() assert not userpath.in_current_path([location1, location2]) assert userpath.prepend([location1, location2]) assert userpath.in_new_path([location1, location2]) @@ -22,7 +24,7 @@ def test_prepend_multiple(): def test_append(): - location = urlsafe_b64encode(urandom(5)).decode() + location = get_random_path() assert not userpath.in_current_path(location) assert userpath.append(location) assert userpath.in_new_path(location) @@ -30,8 +32,8 @@ def test_append(): def test_append_multiple(): - location1 = urlsafe_b64encode(urandom(5)).decode() - location2 = urlsafe_b64encode(urandom(5)).decode() + location1 = get_random_path() + location2 = get_random_path() assert not userpath.in_current_path([location1, location2]) assert userpath.append([location1, location2]) assert userpath.in_new_path([location1, location2]) 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 d2b3e33..86084de 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,11 @@ [tox] +skip_missing_interpreters = true envlist = - py27, - py35, - py36, - py37, - pypy, - pypy3, + py{27,37} [testenv] -passenv = * +usedevelop = true deps = - pytest + -rrequirements-dev.txt commands = - python setup.py --quiet clean develop - pytest -v + pytest -v {posargs} diff --git a/userpath/core.py b/userpath/core.py index 58f5df6..86dc3c6 100644 --- a/userpath/core.py +++ b/userpath/core.py @@ -2,19 +2,19 @@ from .utils import in_current_path -def prepend(location, app_name=None, shells=None, all_shells=False, home=None): +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) + 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): +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) + return interface.put(location, front=False, app_name=app_name, check=check) -def in_new_path(location, shells=None, all_shells=False, home=None): +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) + return interface.location_in_new_path(location, check=check) def need_shell_restart(location, shells=None, all_shells=False, home=None): diff --git a/userpath/interface.py b/userpath/interface.py index e569b05..1a2a51a 100644 --- a/userpath/interface.py +++ b/userpath/interface.py @@ -5,29 +5,30 @@ from io import open from .shells import DEFAULT_SHELLS, SHELLS -from .utils import get_flat_output, get_parent_process_name, location_in_path, normpath +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 - @classmethod - def get_windows_new_path(cls): - return get_flat_output( - [ - 'powershell', '-Command', "& {[Environment]::GetEnvironmentVariable('PATH', 'User')}" - ], - sep='', - shell=True, - ) - - def location_in_new_path(self, location): - location = normpath(location) - new_path = self.get_windows_new_path() - return all(location_in_path(l, new_path) for l in location.split(os.pathsep)) + 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, **kwargs): + def put(self, location, front=True, check=False, **kwargs): location = normpath(location) # PowerShell should always be available on Windows 7 or later. @@ -52,7 +53,7 @@ def put(self, location, front=True, **kwargs): # https://superuser.com/a/601034/766960 subprocess.check_output( ( - 'for /f "skip=2 tokens=3*" %a in (\'reg query HKCU\Environment ' + '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) ), @@ -61,7 +62,7 @@ def put(self, location, front=True, **kwargs): except subprocess.CalledProcessError: return False - return self.location_in_new_path(location) + return self.location_in_new_path(location, check=check) class UnixInterface: @@ -108,23 +109,34 @@ def detect_shell(cls): if shell in SHELLS: return shell - def location_in_new_path(self, location): + def location_in_new_path(self, location, check=False): locations = normpath(location).split(os.pathsep) for shell in self.shells_to_verify: - new_path = get_flat_output(shell.show_path_command()) - if not all(location_in_path(l, new_path) for l in locations): - return False + 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): + 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() @@ -143,7 +155,7 @@ def put(self, location, front=True, app_name=None): except Exception: continue - return self.location_in_new_path(location) + return self.location_in_new_path(location, check=check) __default_interface = WindowsInterface if os.name == 'nt' or platform.system() == 'Windows' else UnixInterface diff --git a/userpath/shells.py b/userpath/shells.py index 2e97dcf..939931d 100644 --- a/userpath/shells.py +++ b/userpath/shells.py @@ -1,5 +1,7 @@ from os import path, pathsep +import distro + DEFAULT_SHELLS = ('bash', 'sh') @@ -18,8 +20,9 @@ def config(self, location, front=True): return {path.join(self.home, '.profile'): 'PATH="{}"'.format(new_path)} @classmethod - def show_path_command(cls): - return ['sh', '-l', '-c', 'echo $PATH'] + 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): @@ -28,17 +31,24 @@ def config(self, location, front=True): new_path = '{}{}{}'.format(head, pathsep, tail) contents = 'export PATH="{}"'.format(new_path) - return { - path.join(self.home, '.bashrc'): contents, + 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. - path.join(self.home, '.bash_profile'): contents, - } + login_config = path.join(self.home, '.bash_profile') + + configs[login_config] = contents + + return configs @classmethod - def show_path_command(cls): - return ['bash', '--login', '-c', 'echo $PATH'] + def show_path_commands(cls): + return [['bash', '-i', '-c', 'echo $PATH'], ['bash', '-i', '-l', '-c', 'echo $PATH']] class Fish(Shell): @@ -52,8 +62,11 @@ def config(self, location, front=True): return {path.join(self.home, '.config', 'fish', 'config.fish'): contents} @classmethod - def show_path_command(cls): - return ['fish', '--login', '-c', 'for p in $PATH; echo "$p"; end'] + 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): @@ -61,15 +74,16 @@ 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)) + 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) + contents = '\n'.join('$PATH.append({!r})'.format(location) for location in locations) return {path.join(self.home, '.xonshrc'): contents} @classmethod - def show_path_command(cls): - return ['xonsh', '--login', '-c', "print('{}'.join($PATH))".format(pathsep)] + def show_path_commands(cls): + command = "print('{}'.join($PATH))".format(pathsep) + return [['xonsh', '-i', '-c', command], ['xonsh', '-i', '--login', '-c', command]] class Zsh(Shell): @@ -81,8 +95,8 @@ def config(self, location, front=True): return {path.join(self.home, '.zshrc'): contents, path.join(self.home, '.zprofile'): contents} @classmethod - def show_path_command(cls): - return ['zsh', '--login', '-c', 'echo $PATH'] + def show_path_commands(cls): + return [['zsh', '-i', '-c', 'echo $PATH'], ['zsh', '-i', '-l', '-c', 'echo $PATH']] SHELLS = { diff --git a/userpath/utils.py b/userpath/utils.py index 43fcebc..3683b9f 100644 --- a/userpath/utils.py +++ b/userpath/utils.py @@ -14,7 +14,6 @@ def normpath(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)) @@ -23,8 +22,15 @@ 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): - output = subprocess.check_output(command, **kwargs).decode('utf-8').strip() + 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()] From ae89c61535cf2deb2eafcf307996a7c1ef3ff58e Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 16:54:45 -0400 Subject: [PATCH 07/19] try --- tests/docker/debian | 2 -- tests/docker/requirements-dev.txt | 3 +++ tox.ini | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/docker/debian b/tests/docker/debian index 544e200..a62ba1e 100644 --- a/tests/docker/debian +++ b/tests/docker/debian @@ -7,6 +7,4 @@ RUN apt-get update \ COPY requirements-dev.txt / RUN pip install -r requirements-dev.txt -RUN pip install xonsh - CMD ["tail", "-f", "/dev/null"] diff --git a/tests/docker/requirements-dev.txt b/tests/docker/requirements-dev.txt index 9c38d8c..c8239e6 100644 --- a/tests/docker/requirements-dev.txt +++ b/tests/docker/requirements-dev.txt @@ -5,3 +5,6 @@ distro # Test deps coverage pytest + +# xonsh shell, if we can +xonsh; python_version > '3.0' diff --git a/tox.ini b/tox.ini index 86084de..ecddc92 100644 --- a/tox.ini +++ b/tox.ini @@ -9,3 +9,7 @@ deps = -rrequirements-dev.txt commands = pytest -v {posargs} + +[testenv:py27] +commands = + pytest -v -m "not xonsh" {posargs} From dba05a46e6e9283e8bbe0c1ec72576e18a2b58d5 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 18:30:52 -0400 Subject: [PATCH 08/19] coverage --- .appveyor.yml | 17 ++++++++++------- .coveragerc | 15 ++++++++++++++- .travis.yml | 6 ++++-- tests/conftest.py | 3 +++ tests/docker/debian | 4 ++-- .../{requirements-dev.txt => requirements.txt} | 0 tox.ini | 12 ++++++++++-- 7 files changed, 43 insertions(+), 14 deletions(-) rename tests/docker/{requirements-dev.txt => requirements.txt} (100%) diff --git a/.appveyor.yml b/.appveyor.yml index 15510fd..cbf3356 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -1,3 +1,9 @@ +image: Visual Studio 2017 +build: off +test: off +cache: + - '%LOCALAPPDATA%\pip\Cache' + skip_branch_with_pr: true branches: only: @@ -6,14 +12,11 @@ branches: 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 tox codecov" test_script: - - "%PYTHON%\\python.exe -m pytest" + - "%PYTHON%\\python.exe -m tox" + - "%PYTHON%\\python.exe -m codecov" 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/.travis.yml b/.travis.yml index b217971..a7e5c5c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,6 +16,8 @@ matrix: env: TOXENV=py37 install: - - pip install tox + - pip install tox codecov -script: tox +script: + - tox -e $TOXENV,coverage + - codecov diff --git a/tests/conftest.py b/tests/conftest.py index 094ee67..97c8fc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,9 @@ def shell_test(request): '-w', '/home/userpath', container, + 'coverage', + 'run', + '-m', 'pytest', 'tests/{}::{}::{}'.format(os.path.basename(request.module.__file__), request.node.name, test_name), ], diff --git a/tests/docker/debian b/tests/docker/debian index a62ba1e..8ece2f4 100644 --- a/tests/docker/debian +++ b/tests/docker/debian @@ -4,7 +4,7 @@ FROM python:${PYTHON_VERSION}-stretch RUN apt-get update \ && apt-get --no-install-recommends -y install fish zsh -COPY requirements-dev.txt / -RUN pip install -r requirements-dev.txt +COPY requirements.txt / +RUN pip install -r requirements.txt CMD ["tail", "-f", "/dev/null"] diff --git a/tests/docker/requirements-dev.txt b/tests/docker/requirements.txt similarity index 100% rename from tests/docker/requirements-dev.txt rename to tests/docker/requirements.txt diff --git a/tox.ini b/tox.ini index ecddc92..80863ce 100644 --- a/tox.ini +++ b/tox.ini @@ -2,14 +2,22 @@ skip_missing_interpreters = true envlist = py{27,37} + coverage [testenv] usedevelop = true deps = -rrequirements-dev.txt commands = - pytest -v {posargs} + coverage run -m pytest -v {posargs} [testenv:py27] commands = - pytest -v -m "not xonsh" {posargs} + coverage run -m pytest -v -m "not xonsh" {posargs} + +[testenv:coverage] +skip_install = true +deps = coverage +commands = + coverage combine + coverage report From 7258ceea60207cd82f6ac83208c77ec1e0adf953 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 18:56:20 -0400 Subject: [PATCH 09/19] fix coverage --- .appveyor.yml | 2 +- .travis.yml | 3 +-- tox.ini | 8 ++++++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.appveyor.yml b/.appveyor.yml index cbf3356..e75048d 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -19,4 +19,4 @@ install: test_script: - "%PYTHON%\\python.exe -m tox" - - "%PYTHON%\\python.exe -m codecov" + - "%PYTHON%\\python.exe -m tox -e codecov" diff --git a/.travis.yml b/.travis.yml index a7e5c5c..290ca52 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,5 +19,4 @@ install: - pip install tox codecov script: - - tox -e $TOXENV,coverage - - codecov + - tox -e $TOXENV,coverage,codecov diff --git a/tox.ini b/tox.ini index 80863ce..d5650b7 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ envlist = [testenv] usedevelop = true +passenv = APPVEYOR deps = -rrequirements-dev.txt commands = @@ -21,3 +22,10 @@ deps = coverage commands = coverage combine coverage report + +[testenv:codecov] +skip_install = true +deps = coverage codecov +commands = + coverage xml + codecov -X gcov -f coverage.xml From e6491ffb1a4a28e4f30e8991a844bd8e35685607 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 19:03:56 -0400 Subject: [PATCH 10/19] CI old --- .appveyor.yml | 1 + tox.ini | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.appveyor.yml b/.appveyor.yml index e75048d..ed614ef 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -15,6 +15,7 @@ environment: - PYTHON: "C:\\Python37-x64" install: + - "%PYTHON%\\python.exe -m pip install -U pip" - "%PYTHON%\\python.exe -m pip install tox codecov" test_script: diff --git a/tox.ini b/tox.ini index d5650b7..12dea6c 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,9 @@ commands = [testenv:codecov] skip_install = true -deps = coverage codecov +deps = + coverage + codecov commands = coverage xml codecov -X gcov -f coverage.xml From a5170d03b331c5cc350a5ff4215a8937705b6e33 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 19:24:32 -0400 Subject: [PATCH 11/19] fix codecov --- tox.ini | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tox.ini b/tox.ini index 12dea6c..f86f13e 100644 --- a/tox.ini +++ b/tox.ini @@ -25,6 +25,14 @@ commands = [testenv:codecov] skip_install = true +passenv = + APPVEYOR + APPVEYOR_* + CI + CODECOV_* + TOXENV + TRAVIS + TRAVIS_* deps = coverage codecov From cfbb18afd042ee879dae6bb13951052ca9f59c1e Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 19:40:50 -0400 Subject: [PATCH 12/19] Update .codecov.yml --- .codecov.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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' From b82e62f519404076e7b07a9acdb15f3c68cbed8b Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 21:18:02 -0400 Subject: [PATCH 13/19] better cli errors --- setup.py | 4 +--- tests/test_windows.py | 26 ++++++++++++-------------- userpath/cli.py | 20 ++++++++++++-------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/setup.py b/setup.py index a1cc53e..5a32b51 100644 --- a/setup.py +++ b/setup.py @@ -42,12 +42,10 @@ 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2.7', - '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, diff --git a/tests/test_windows.py b/tests/test_windows.py index e3f7188..643e2a9 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -9,32 +9,30 @@ def test_prepend(): location = get_random_path() assert not userpath.in_current_path(location) - assert userpath.prepend(location) + assert userpath.prepend(location, check=True) assert userpath.in_new_path(location) assert userpath.need_shell_restart(location) def test_prepend_multiple(): - location1 = get_random_path() - location2 = get_random_path() - 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]) + 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) + assert userpath.append(location, check=True) assert userpath.in_new_path(location) assert userpath.need_shell_restart(location) def test_append_multiple(): - location1 = get_random_path() - location2 = get_random_path() - 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]) + 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/userpath/cli.py b/userpath/cli.py index d092681..c4d0bc2 100644 --- a/userpath/cli.py +++ b/userpath/cli.py @@ -69,11 +69,13 @@ def prepend(locations, shells, all_shells, home, force): )) sys.exit(2) - if up.prepend(locations, shells=shells, all_shells=all_shells, home=home): - 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: + echo_success('Success!') @userpath.command(context_settings=CONTEXT_SETTINGS, short_help='Appends to the user PATH') @@ -118,11 +120,13 @@ def append(locations, shells, all_shells, home, force): )) sys.exit(2) - if up.append(locations, shells=shells, all_shells=all_shells, home=home): - 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: + echo_success('Success!') @userpath.command(context_settings=CONTEXT_SETTINGS, short_help='Checks if locations are in the user PATH') From c3c9b6947605056923dce5ffd63357c29bdb4dd2 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 23:12:17 -0400 Subject: [PATCH 14/19] Update README.rst --- README.rst | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index e03499e..5e6c4b0 100644 --- a/README.rst +++ b/README.rst @@ -1,25 +1,33 @@ 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&logoColor=white&label=Travis%20CI :target: https://travis-ci.org/ofek/userpath :alt: Travis CI -.. 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&logoColor=white&label=AppVeyor :target: https://ci.appveyor.com/project/ofek/userpath :alt: AppVeyor CI -.. image:: https://img.shields.io/pypi/pyversions/userpath.svg?style=flat-square +.. 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/v/userpath.svg?logo=python&logoColor=white + :target: https://pypi.org/project/userpath + :alt: PyPI - Version + +.. image:: https://img.shields.io/pypi/pyversions/userpath.svg?logo=python&logoColor=white :target: https://pypi.org/project/userpath - :alt: Supported Python versions + :alt: PyPI - Supported Python versions + +.. image:: https://pepy.tech/badge/userpath + :target: https://pepy.tech/project/userpath + :alt: PyPI - Downloads -.. image:: https://img.shields.io/pypi/l/userpath.svg?style=flat-square +.. 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 ----- @@ -38,9 +46,8 @@ Installation userpath is distributed on `PyPI `_ as a universal wheel and is available on Linux/macOS and Windows and supports -Python 2.7/3.4+ and PyPy. - -.. code-block:: bash +Python 2.7/3.6+ and PyPy. +:: $ pip install userpath @@ -48,8 +55,7 @@ Commands -------- Only 3! - -.. code-block:: bash +:: $ userpath -h Usage: userpath [OPTIONS] COMMAND [ARGS]... From fe908239739dd0e0901841d385f088da1d96d4a4 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 23:14:39 -0400 Subject: [PATCH 15/19] re-order --- README.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 5e6c4b0..20ac5c7 100644 --- a/README.rst +++ b/README.rst @@ -13,14 +13,14 @@ userpath :target: https://codecov.io/github/ofek/userpath?branch=master :alt: Codecov -.. image:: https://img.shields.io/pypi/v/userpath.svg?logo=python&logoColor=white - :target: https://pypi.org/project/userpath - :alt: PyPI - Version - .. image:: https://img.shields.io/pypi/pyversions/userpath.svg?logo=python&logoColor=white :target: https://pypi.org/project/userpath :alt: PyPI - Supported Python versions +.. image:: https://img.shields.io/pypi/v/userpath.svg?logo=python&logoColor=white + :target: https://pypi.org/project/userpath + :alt: PyPI - Version + .. image:: https://pepy.tech/badge/userpath :target: https://pepy.tech/project/userpath :alt: PyPI - Downloads From 7a90af386d89c536ab571a8078d4c6000f50d137 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 27 May 2019 23:43:07 -0400 Subject: [PATCH 16/19] badges --- README.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 20ac5c7..1fd63b4 100644 --- a/README.rst +++ b/README.rst @@ -1,11 +1,11 @@ userpath ======== -.. image:: https://img.shields.io/travis/ofek/userpath/master.svg?logo=travis&logoColor=white&label=Travis%20CI +.. 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 -.. image:: https://img.shields.io/appveyor/ci/ofek/userpath/master.svg?logo=appveyor&logoColor=white&label=AppVeyor +.. 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 @@ -13,19 +13,19 @@ userpath :target: https://codecov.io/github/ofek/userpath?branch=master :alt: Codecov -.. image:: https://img.shields.io/pypi/pyversions/userpath.svg?logo=python&logoColor=white +.. 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/v/userpath.svg?logo=python&logoColor=white +.. image:: https://img.shields.io/pypi/v/userpath.svg?logo=python&label=PyPI&logoColor=gold :target: https://pypi.org/project/userpath :alt: PyPI - Version -.. image:: https://pepy.tech/badge/userpath - :target: https://pepy.tech/project/userpath +.. 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 +.. image:: https://img.shields.io/badge/License-MIT%2FApache--2.0-9400d3.svg :target: https://choosealicense.com/licenses :alt: License: MIT/Apache-2.0 From 6ace5e644f3623f36c627e0711148a9f6147e9ae Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Tue, 28 May 2019 00:01:28 -0400 Subject: [PATCH 17/19] Update README.rst --- README.rst | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 1fd63b4..24e5cfe 100644 --- a/README.rst +++ b/README.rst @@ -3,16 +3,16 @@ userpath .. 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?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 @@ -24,11 +24,15 @@ userpath .. 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: MIT/Apache-2.0 +.. image:: https://img.shields.io/badge/say-thanks-ff69b4.svg + :target: https://saythanks.io/to/ofek + :alt: Say Thanks + ----- Ever wanted to release a cool new app but found it difficult to add its From eb2b1a8b99dfec836059d298bc47b48f0f82cb6f Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Sat, 13 Jul 2019 13:31:14 -0400 Subject: [PATCH 18/19] address review --- userpath/cli.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/userpath/cli.py b/userpath/cli.py index c4d0bc2..aa9eb3c 100644 --- a/userpath/cli.py +++ b/userpath/cli.py @@ -44,11 +44,14 @@ def userpath(): '-a', '--all-shells', is_flag=True, - help='Update PATH of all supported shells. This has no effect on Windows as that is the standard behavior.', + 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.') -def prepend(locations, shells, all_shells, home, force): +@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. """ @@ -75,7 +78,8 @@ def prepend(locations, shells, all_shells, home, force): echo_failure(str(e)) sys.exit(1) else: - echo_success('Success!') + if not quiet: + echo_success('Success!') @userpath.command(context_settings=CONTEXT_SETTINGS, short_help='Appends to the user PATH') @@ -95,11 +99,14 @@ def prepend(locations, shells, all_shells, home, force): '-a', '--all-shells', is_flag=True, - help='Update PATH of all supported shells. This has no effect on Windows as that is the standard behavior.', + 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.') -def append(locations, shells, all_shells, home, force): +@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. """ @@ -126,7 +133,8 @@ def append(locations, shells, all_shells, home, force): echo_failure(str(e)) sys.exit(1) else: - echo_success('Success!') + if not quiet: + echo_success('Success!') @userpath.command(context_settings=CONTEXT_SETTINGS, short_help='Checks if locations are in the user PATH') @@ -146,14 +154,18 @@ def append(locations, shells, all_shells, home, force): '-a', '--all-shells', is_flag=True, - help='Update PATH of all supported shells. This has no effect on Windows as that is the standard behavior.', + 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.') -def verify(locations, shells, all_shells, home): +@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)) + 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) From bb93c504f4a448b548b0383509604fc2db304a09 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Sat, 13 Jul 2019 15:19:34 -0400 Subject: [PATCH 19/19] never --- userpath/interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userpath/interface.py b/userpath/interface.py index 1a2a51a..61ad26d 100644 --- a/userpath/interface.py +++ b/userpath/interface.py @@ -45,7 +45,7 @@ def put(self, location, front=True, check=False, **kwargs): ], shell=True, ) - except subprocess.CalledProcessError: + except subprocess.CalledProcessError: # no cov try: head, tail = (location, '%~a') if front else ('%~a', location) new_path = '{}{}{}'.format(head, os.pathsep, tail)