diff --git a/.gitignore b/.gitignore index db4561e..f8f8ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,12 @@ docs/_build/ # PyBuilder target/ + +# vscode +.vscode/ + +# mypy +.mypy_cache/ + +# pytest +.pytest_cache diff --git a/.pylintrc b/.pylintrc index 5fca41b..bbc0593 100644 --- a/.pylintrc +++ b/.pylintrc @@ -50,7 +50,8 @@ confidence= # Disable R0901 to hide warnings caused by use of mixins. # Disable W0201 to hide warnings caused by use of mixins. # Disable R0205 to hide Python3.X warnings of inheriting from object. -disable=F0401,R0901,W0201,R0205 +# Disable C0330 to prevent indentation conflicts between pylint and black. +disable=F0401,R0901,W0201,R0205,C0330 [MISCELLANEOUS] @@ -127,7 +128,7 @@ generated-members= spelling-dict=en_US # List of comma separated words that should not be checked. -spelling-ignore-words=config,namespace,iterable,json,ini,regex,namespaces,str,JSON,INI,init,behaviour,setattr,getattr,classmethod,instancemethod,dict,bool,metadata,iteritems,args,kwargs,cls,iter,subclass,subclasses,api,API,unicode,_namespaces,NAMESPACES,_NAMESPACES,proxied,hasattr,prepend,prepended,os,env,cli,CLI,arg,args,argv,url,URL,uninstall,mixin,Mixin,pip,subshell,csh,sh,symlink,symlinked,symlinking,pypi,cmd,txt,virtualenv,reinstall +spelling-ignore-words=config,namespace,iterable,json,ini,regex,namespaces,str,JSON,INI,init,behaviour,setattr,getattr,classmethod,instancemethod,dict,bool,metadata,iteritems,args,kwargs,cls,iter,subclass,subclasses,api,API,unicode,_namespaces,NAMESPACES,_NAMESPACES,proxied,hasattr,prepend,prepended,os,env,cli,CLI,arg,args,argv,url,URL,uninstall,mixin,Mixin,pip,subshell,csh,sh,symlink,symlinked,symlinking,pypi,cmd,txt,virtualenv,reinstall,pth,venv,recurse,xsh # A path to a file that contains private dictionary; one word per line. spelling-private-dict-file= diff --git a/setup.py b/setup.py index b08fe92..65cd295 100644 --- a/setup.py +++ b/setup.py @@ -4,24 +4,22 @@ from setuptools import find_packages -with open('README.rst', 'r') as readmefile: +with open("README.rst", "r") as readmefile: README = readmefile.read() setup( - name='venvctrl', - version='0.3.0', - url='https://github.com/kevinconway/venvctrl', - description='API for virtual environments.', + name="venvctrl", + version="0.4.0", + url="https://github.com/kevinconway/venvctrl", + description="API for virtual environments.", author="Kevin Conway", author_email="kevinjacobconway@gmail.com", long_description=README, - license='MIT', - packages=find_packages(exclude=['tests', 'build', 'dist', 'docs']), + license="MIT", + packages=find_packages(exclude=["tests", "build", "dist", "docs"]), entry_points={ - 'console_scripts': [ - 'venvctrl-relocate = venvctrl.cli.relocate:main', - ], + "console_scripts": ["venvctrl-relocate = venvctrl.cli.relocate:main"] }, include_package_data=True, ) diff --git a/tests/test_virtual_environment.py b/tests/test_virtual_environment.py index 1ef59fa..8fc7448 100644 --- a/tests/test_virtual_environment.py +++ b/tests/test_virtual_environment.py @@ -13,13 +13,13 @@ from venvctrl import api -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def random(): """Get a random UUID.""" return str(uuid.uuid4()) -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def venv(random, tmpdir): """Get an initialized venv.""" v = api.VirtualEnvironment(str(tmpdir.join(random))) @@ -37,22 +37,22 @@ def test_create(random, tmpdir): def test_pip(venv): """Test the ability to manage packages with pip.""" - venv.install_package('confpy') - assert venv.has_package('confpy') - venv.uninstall_package('confpy') - assert not venv.has_package('confpy') - path = os.path.join(venv.path, '..', 'requirements.txt') - with open(path, 'w') as req_file: + venv.install_package("confpy") + assert venv.has_package("confpy") + venv.uninstall_package("confpy") + assert not venv.has_package("confpy") + path = os.path.join(venv.path, "..", "requirements.txt") + with open(path, "w") as req_file: - req_file.write('confpy{0}'.format(os.linesep)) + req_file.write("confpy{0}".format(os.linesep)) venv.install_requirements(path) - assert venv.has_package('confpy') + assert venv.has_package("confpy") def test_relocate(venv): """Test the ability to relocate a venv.""" - path = '/testpath' + path = "/testpath" pypy_shebang = "#!/usr/bin/env pypy" f = open(venv.bin.abspath + "/pypy_shebang.py", "w") f.write(pypy_shebang) @@ -66,17 +66,17 @@ def test_relocate(venv): if script.shebang: - assert script.shebang == '#!{0}/bin/python'.format( - path, - ) + assert script.shebang == "#!{0}/bin/python".format(path) def test_relocate_long_shebang(venv): """Test the ability to relocate a venv.""" - path = '/testpath' - long_shebang = "#!/bin/sh{0}" \ - "'''exec' /tmp/rpmbuild/python \"$0\" \"$@\"{0}" \ - "' '''{0}".format(os.linesep) + path = "/testpath" + long_shebang = ( + "#!/bin/sh{0}" + "'''exec' /tmp/rpmbuild/python \"$0\" \"$@\"{0}" + "' '''{0}".format(os.linesep) + ) f = open(venv.bin.abspath + "/long_shebang.py", "w") f.write(long_shebang) f.close() @@ -89,17 +89,41 @@ def test_relocate_long_shebang(venv): if shebang: shebang = shebang.split(os.linesep) if len(shebang) == 1: - assert shebang == ['#!{0}/bin/python'.format( - path - )] + assert shebang == ["#!{0}/bin/python".format(path)] elif len(shebang) == 3: - assert shebang == \ - ['#!/bin/sh', - '\'\'\'exec\' {0}/bin/python "$0" "$@"'.format( - path), - "' '''"] + assert shebang == [ + "#!/bin/sh", + "'''exec' {0}/bin/python \"$0\" \"$@\"".format(path), + "' '''", + ] else: assert False, "Invalid shebang length: {0}, {1}".format( - len(shebang), script.shebang) + len(shebang), script.shebang + ) + + +def test_relocate_no_original_path(venv): + """Test that the original path is not found in files.""" + path = "/testpath" + original_path = venv.abspath + f = open(venv.bin.abspath + "/something.pth", "w") + f.write(original_path) + f.close() + venv.relocate(path) + dirs = list(venv.dirs) + files = list(venv.files) + while dirs or files: + for file_ in files: + with open(file_.abspath, "r") as source: + try: + lines = source.readlines() + except UnicodeDecodeError: + # Skip any non-text files. Binary files are out of + # scope for this test. + continue + for line in lines: + assert original_path not in line, file_.abspath + next_dir = dirs.pop() + files = list(next_dir.files) diff --git a/tox.ini b/tox.ini index 07d0de3..91ebed6 100644 --- a/tox.ini +++ b/tox.ini @@ -9,8 +9,8 @@ commands=py.test tests/ [testenv:pep8] commands= - pep8 venvctrl/ - pep8 tests/ + pycodestyle venvctrl/ + pycodestyle tests/ [testenv:pyflakes] commands= diff --git a/venvctrl/api.py b/venvctrl/api.py index 24ab680..214ac21 100644 --- a/venvctrl/api.py +++ b/venvctrl/api.py @@ -13,11 +13,11 @@ class VirtualEnvironment( - base.VirtualEnvironment, - command.CommandMixin, - create.CreateMixin, - pip.PipMixin, - relocate.RelocateMixin, + base.VirtualEnvironment, + command.CommandMixin, + create.CreateMixin, + pip.PipMixin, + relocate.RelocateMixin, ): """Virtual environment management class.""" diff --git a/venvctrl/cli/relocate.py b/venvctrl/cli/relocate.py index ea66f33..63cb81d 100644 --- a/venvctrl/cli/relocate.py +++ b/venvctrl/cli/relocate.py @@ -31,29 +31,27 @@ def relocate(source, destination, move=False): def main(): """Relocate a virtual environment.""" parser = argparse.ArgumentParser( - description='Relocate a virtual environment.' + description="Relocate a virtual environment." ) parser.add_argument( - '--source', - help='The existing virtual environment.', - required=True, + "--source", help="The existing virtual environment.", required=True ) parser.add_argument( - '--destination', - help='The location for which to configure the virtual environment.', + "--destination", + help="The location for which to configure the virtual environment.", required=True, ) parser.add_argument( - '--move', - help='Move the virtual environment to the destination.', + "--move", + help="Move the virtual environment to the destination.", default=False, - action='store_true', + action="store_true", ) args = parser.parse_args() relocate(args.source, args.destination, args.move) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/venvctrl/venv/base.py b/venvctrl/venv/base.py index df3ac01..73b50fb 100644 --- a/venvctrl/venv/base.py +++ b/venvctrl/venv/base.py @@ -75,13 +75,13 @@ def writeline(self, line, line_number): line_number (int): The line of the file to rewrite. Numbering starts at 0. """ - tmp_file = tempfile.TemporaryFile('w+') + tmp_file = tempfile.TemporaryFile("w+") if not line.endswith(os.linesep): line += os.linesep try: - with open(self.path, 'r') as file_handle: + with open(self.path, "r") as file_handle: for count, new_line in enumerate(file_handle): @@ -92,7 +92,7 @@ def writeline(self, line, line_number): tmp_file.write(new_line) tmp_file.seek(0) - with open(self.path, 'w') as file_handle: + with open(self.path, "w") as file_handle: for new_line in tmp_file: @@ -150,18 +150,22 @@ class BinFile(VenvFile): @property def shebang(self): """Get the file shebang if is has one.""" - with open(self.path, 'rb') as file_handle: + with open(self.path, "rb") as file_handle: hashtag = file_handle.read(2) - if hashtag == b'#!': + if hashtag == b"#!": # Check if we're using the new style shebang - new_style_shebang = \ - file_handle.readline() == b'/bin/sh\n' and \ - file_handle.read(8) == b"'''exec'" + new_style_shebang = ( + file_handle.readline() == b"/bin/sh\n" + and file_handle.read(8) == b"'''exec'" + ) file_handle.seek(0) return os.linesep.join( - [next(file_handle).decode('utf8').strip() for _ - in range(3 if new_style_shebang else 1)]) + [ + next(file_handle).decode("utf8").strip() + for _ in range(3 if new_style_shebang else 1) + ] + ) return None @@ -176,23 +180,24 @@ def shebang(self, new_shebang): if not self.shebang: - raise ValueError('Cannot modify a shebang if it does not exist.') + raise ValueError("Cannot modify a shebang if it does not exist.") if new_shebang is None: - raise ValueError('New shebang cannot be None.') + raise ValueError("New shebang cannot be None.") old_shebang = self.shebang.strip().split(os.linesep) new_shebang = new_shebang.strip().split(os.linesep) if len(old_shebang) != len(new_shebang): - raise ValueError('Old and new shebangs must ' - 'be same number of lines') + raise ValueError( + "Old and new shebangs must " "be same number of lines" + ) - if not new_shebang[0].startswith('#!'): + if not new_shebang[0].startswith("#!"): - raise ValueError('Invalid shebang.') + raise ValueError("Invalid shebang.") for line_num, line in enumerate(new_shebang): self.writeline(line, line_num) @@ -207,7 +212,7 @@ class ActivateFile(BinFile): def _find_vpath(self): """Find the VIRTUAL_ENV path entry.""" - with open(self.path, 'r') as file_handle: + with open(self.path, "r") as file_handle: for count, line in enumerate(file_handle): @@ -247,6 +252,14 @@ class ActivateCshFile(ActivateFile): write_pattern = 'setenv VIRTUAL_ENV "{0}"' +class ActivateXshFile(ActivateFile): + + """The virtual environment /bin/activate.xsh script.""" + + read_pattern = re.compile(r'^\$VIRTUAL_ENV = r"(.*)"$') + write_pattern = '$VIRTUAL_ENV = r"{0}"' + + class BinDir(VenvDir): """Specialized VenvDir for the /bin directory.""" @@ -254,22 +267,36 @@ class BinDir(VenvDir): @property def activates(self): """Get an iter of activate files in the virtual environment.""" - return (self.activate_sh, self.activate_csh, self.activate_fish) + return ( + activation + for activation in ( + self.activate_sh, + self.activate_csh, + self.activate_fish, + self.activate_xsh, + ) + if activation.exists + ) @property def activate_sh(self): """Get the /bin/activate script.""" - return ActivateFile(os.path.join(self.path, 'activate')) + return ActivateFile(os.path.join(self.path, "activate")) @property def activate_csh(self): """Get the /bin/activate.csh script.""" - return ActivateCshFile(os.path.join(self.path, 'activate.csh')) + return ActivateCshFile(os.path.join(self.path, "activate.csh")) @property def activate_fish(self): """Get the /bin/activate.fish script.""" - return ActivateFishFile(os.path.join(self.path, 'activate.fish')) + return ActivateFishFile(os.path.join(self.path, "activate.fish")) + + @property + def activate_xsh(self): + """Get the /bin/activate.xsh script.""" + return ActivateXshFile(os.path.join(self.path, "activate.xsh")) @property def files(self): @@ -303,19 +330,19 @@ class VirtualEnvironment(VenvDir): @property def bin(self): """Get the /bin directory.""" - return BinDir(os.path.join(self.path, 'bin')) + return BinDir(os.path.join(self.path, "bin")) @property def include(self): """Get the /include directory.""" - return VenvDir(os.path.join(self.path, 'include')) + return VenvDir(os.path.join(self.path, "include")) @property def lib(self): """Get the /lib directory.""" - return VenvDir(os.path.join(self.path, 'lib')) + return VenvDir(os.path.join(self.path, "lib")) @property def local(self): """Get the /local directory.""" - return VenvDir(os.path.join(self.path, 'local')) + return VenvDir(os.path.join(self.path, "local")) diff --git a/venvctrl/venv/command.py b/venvctrl/venv/command.py index c172840..603cc51 100644 --- a/venvctrl/venv/command.py +++ b/venvctrl/venv/command.py @@ -11,7 +11,7 @@ import sys -CommandResult = collections.namedtuple('CommandResult', ('code', 'out', 'err')) +CommandResult = collections.namedtuple("CommandResult", ("code", "out", "err")) class CommandMixin(object): @@ -24,26 +24,22 @@ def _execute(cmd): cmd_parts = shlex.split(cmd) if sys.version_info[0] < 3: - cmd_parts = shlex.split(cmd.encode('ascii')) + cmd_parts = shlex.split(cmd.encode("ascii")) proc = subprocess.Popen( - cmd_parts, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + cmd_parts, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) out, err = proc.communicate() if proc.returncode != 0: raise subprocess.CalledProcessError( - returncode=proc.returncode, - cmd=cmd, - output=err, + returncode=proc.returncode, cmd=cmd, output=err ) return CommandResult( code=proc.returncode, - out=out.decode('utf8'), - err=err.decode('utf8'), + out=out.decode("utf8"), + err=err.decode("utf8"), ) def cmd_path(self, cmd): @@ -60,11 +56,11 @@ def cmd_path(self, cmd): """ for binscript in self.bin.files: - if binscript.path.endswith('/{0}'.format(cmd)): + if binscript.path.endswith("/{0}".format(cmd)): return binscript.path - raise ValueError('The command {0} was not found.'.format(cmd)) + raise ValueError("The command {0} was not found.".format(cmd)) def run(self, cmd): """Execute a script from the virtual environment /bin directory.""" @@ -72,12 +68,12 @@ def run(self, cmd): def python(self, cmd): """Execute a python script using the virtual environment python.""" - python_bin = self.cmd_path('python') - cmd = '{0} {1}'.format(python_bin, cmd) + python_bin = self.cmd_path("python") + cmd = "{0} {1}".format(python_bin, cmd) return self._execute(cmd) def pip(self, cmd): """Execute some pip function using the virtual environment pip.""" - pip_bin = self.cmd_path('pip') - cmd = '{0} {1}'.format(pip_bin, cmd) + pip_bin = self.cmd_path("pip") + cmd = "{0} {1}".format(pip_bin, cmd) return self._execute(cmd) diff --git a/venvctrl/venv/create.py b/venvctrl/venv/create.py index af50fec..3301d24 100644 --- a/venvctrl/venv/create.py +++ b/venvctrl/venv/create.py @@ -24,18 +24,18 @@ def create(self, python=None, system_site=False, always_copy=False): always_copy (bool): Whether or not to force copying instead of symlinking in the virtual environment. Default is False. """ - command = 'virtualenv' + command = "virtualenv" if python: - command = '{0} --python={1}'.format(command, python) + command = "{0} --python={1}".format(command, python) if system_site: - command = '{0} --system-site-packages'.format(command) + command = "{0} --system-site-packages".format(command) if always_copy: - command = '{0} --always-copy'.format(command) + command = "{0} --always-copy".format(command) - command = '{0} {1}'.format(command, self.path) + command = "{0} {1}".format(command, self.path) self._execute(command) diff --git a/venvctrl/venv/pip.py b/venvctrl/venv/pip.py index 0342b9f..af5b15f 100644 --- a/venvctrl/venv/pip.py +++ b/venvctrl/venv/pip.py @@ -22,7 +22,7 @@ def has_package(self, name): Returns: bool: True if installed else false. """ - return name in self.pip('list').out + return name in self.pip("list").out def install_package(self, name, index=None, force=False, update=False): """Install a given package. @@ -34,20 +34,20 @@ def install_package(self, name, index=None, force=False, update=False): force (bool): For the reinstall of packages during updates. update (bool): Update the package if it is out of date. """ - cmd = 'install' + cmd = "install" if force: - cmd = '{0} {1}'.format(cmd, '--force-reinstall') + cmd = "{0} {1}".format(cmd, "--force-reinstall") if update: - cmd = '{0} {1}'.format(cmd, '--update') + cmd = "{0} {1}".format(cmd, "--update") if index: - cmd = '{0} {1}'.format(cmd, '--index-url {0}'.format(index)) + cmd = "{0} {1}".format(cmd, "--index-url {0}".format(index)) - self.pip('{0} {1}'.format(cmd, name)) + self.pip("{0} {1}".format(cmd, name)) def install_requirements(self, path, index=None): """Install packages from a requirements.txt file. @@ -56,10 +56,10 @@ def install_requirements(self, path, index=None): path (str): The path to the requirements file. index (str): The URL for a pypi index to use. """ - cmd = 'install -r {0}'.format(path) + cmd = "install -r {0}".format(path) if index: - cmd = 'install --index-url {0} -r {1}'.format(index, path) + cmd = "install --index-url {0} -r {1}".format(index, path) self.pip(cmd) @@ -69,4 +69,4 @@ def uninstall_package(self, name): Args: name (str): The name of the package to uninstall. """ - self.pip('{0} --yes {1}'.format('uninstall', name)) + self.pip("{0} --yes {1}".format("uninstall", name)) diff --git a/venvctrl/venv/relocate.py b/venvctrl/venv/relocate.py index f027726..1136b17 100644 --- a/venvctrl/venv/relocate.py +++ b/venvctrl/venv/relocate.py @@ -34,19 +34,47 @@ def relocate(self, destination): shebang = shebang.strip().split(os.linesep) if len(shebang) == 1 and ( - 'python' in shebang[0] or 'pypy' in shebang[0] + "python" in shebang[0] or "pypy" in shebang[0] ): - binfile.shebang = '#!{0}'.format( - os.path.join(destination, 'bin', 'python') + binfile.shebang = "#!{0}".format( + os.path.join(destination, "bin", "python") ) elif len(shebang) == 3 and ( - 'python' in shebang[1] or 'pypy' in shebang[1] + "python" in shebang[1] or "pypy" in shebang[1] ): - shebang[1] = '\'\'\'exec\' {0} "$0" "$@"'.format( - os.path.join(destination, 'bin', 'python') + shebang[1] = "'''exec' {0} \"$0\" \"$@\"".format( + os.path.join(destination, "bin", "python") ) binfile.shebang = os.linesep.join(shebang) + # Even though wheel is the official format, there are still several + # cases in the wild where eggs are being installed. Eggs come with the + # possibility of .pth files. Each .pth file contains the path to where + # a module can be found. To handle them we must recurse the entire + # venv file tree since they can be either at the root of the + # site-packages, bundled within an egg directory, or both. + original_path = self.path + original_abspath = self.abspath + dirs = list(self.dirs) + files = list(self.files) + while dirs or files: + for file_ in files: + if file_.abspath.endswith(".pth"): + content = "" + with open(file_.abspath, "r") as source: + # .pth files are almost always very small. Because of + # this we read the whole file as a convenience. + content = source.read() + # It's not certain whether the .pth will have a relative + # or absolute path so we replace both in order of most to + # least specific. + content = content.replace(original_abspath, destination) + content = content.replace(original_path, destination) + with open(file_.abspath, "w") as source: + source.write(content) + next_dir = dirs.pop() + files = list(next_dir.files) + def move(self, destination): """Reconfigure and move the virtual environment to another path.