diff --git a/README.rst b/README.rst index 3e3ff62..7d3748e 100644 --- a/README.rst +++ b/README.rst @@ -470,6 +470,12 @@ virtual environment matching the provided UUID if such environment exists (one e way to find out the environment's UUID is calling *fades* with the ``--where`` option). +Alternatively, you can pass ``--rm`` without a UUID and indicate the dependencies +instead; *fades* will then remove the virtual environment that matches those +dependencies, just like it would select it to run something:: + + fades --rm -d django + Another way to clean up the cache is to remove all venvs that haven't been used for some time. In order to do this you need to call *fades* with ``--clean-unused-venvs``. When fades it's called with this option, it runs in mantain mode, this means that fades will exit @@ -519,6 +525,10 @@ Remove a virtual environment matching the given uuid from disk and cache index:: fades --rm 89a2bf83-c280-4918-a78d-c35506efd69d +Remove the virtual environment built for the dependencies indicated inside a script, from disk and cache index:: + + fades --rm script.py + Download the script from the given pastebin and executes it (previously building a virtual environment for the dependencies indicated in that pastebin, of course):: fades http://linkode.org/#4QI4TrPlGf1gK2V7jPBC47 diff --git a/fades/main.py b/fades/main.py index 0a3f47a..20dcc4b 100644 --- a/fades/main.py +++ b/fades/main.py @@ -25,6 +25,7 @@ import sys import subprocess import tempfile +import uuid from pathlib import Path import fades @@ -263,8 +264,10 @@ def go(): '--python-options', action='append', default=[], help="extra options to be supplied to python (this option can be used multiple times)") parser.add_argument( - '--rm', dest='remove', metavar='UUID', - help="remove a virtualenv by UUID; see --where option to easily find out the UUID") + '--rm', dest='remove', metavar='UUID', nargs='?', const='', + help="remove a virtualenv: by UUID if one is given (see --where to easily find out the " + "UUID), or by the indicated dependencies if no UUID is passed " + "(e.g. 'fades --rm -d django')") parser.add_argument( '--clean-unused-venvs', action='store', help="remove venvs that haven't been used for more than the indicated days and compact " @@ -311,6 +314,18 @@ def go(): # update args from config file (if needed). args = file_options.options_from_file(cli_args) + # '--rm' takes an optional UUID, so in 'fades --rm script.py' argparse grabs the script as + # its value; venv UUIDs are always valid UUIDs, so anything else is really the child program + if args.remove is not None: + try: + uuid.UUID(args.remove) + except ValueError: + if args.remove: + if args.child_program is not None: + args.child_options.insert(0, args.child_program) + args.child_program = args.remove + args.remove = '' + # validate input, parameters, and support some special options if args.version: print("Running 'fades' version", fades.__version__) @@ -363,22 +378,6 @@ def go(): usage_manager.clean_unused_venvs(max_days_to_keep) return 0 - uuid = args.remove - if uuid: - venv_data = venvscache.get_venv(uuid=uuid) - if venv_data: - # remove this venv from the cache - env_path = venv_data.get('env_path') - if env_path: - envbuilder.destroy_venv(env_path, venvscache) - else: - logger.warning( - "Invalid 'env_path' found in virtualenv metadata: %r. " - "Not removing virtualenv.", env_path) - else: - logger.warning('No virtualenv found with uuid: %s.', uuid) - return 0 - # decided which the child program really is analyzable_child_program, child_program = decide_child_program( args.executable, args.module, args.child_program) @@ -387,10 +386,6 @@ def go(): indicated_deps = consolidate_dependencies( args.ipython, analyzable_child_program, args.requirement, args.dependency) - # Check for packages updates - if args.check_updates: - helpers.check_pypi_updates(indicated_deps) - # decide the interpreter to use, considering the one indicated with --python and the # Python version the script declares it needs in its '# /// script' metadata block, if any # (this may find in PATH a different interpreter than fades' own) @@ -408,8 +403,33 @@ def go(): if args.system_site_packages: options['venv_options'].append("--system-site-packages") + # remove a virtualenv, either by the given UUID or by the indicated dependencies + if args.remove is not None: + if args.remove: + venv_data = venvscache.get_venv(uuid=args.remove) + descriptor = "uuid: {}".format(args.remove) + else: + venv_data = venvscache.get_venv(indicated_deps, interpreter, options=options) + descriptor = "the indicated dependencies" + if venv_data: + # remove this venv from the cache + env_path = venv_data.get('env_path') + if env_path: + envbuilder.destroy_venv(env_path, venvscache) + else: + logger.warning( + "Invalid 'env_path' found in virtualenv metadata: %r. " + "Not removing virtualenv.", env_path) + else: + logger.warning('No virtualenv found with %s.', descriptor) + return 0 + + # Check for packages updates + if args.check_updates: + helpers.check_pypi_updates(indicated_deps) + create_venv = False - venv_data = venvscache.get_venv(indicated_deps, interpreter, uuid, options) + venv_data = venvscache.get_venv(indicated_deps, interpreter, options=options) if venv_data: env_path = venv_data['env_path'] # A venv was found in the cache check if its valid or re-generate it. diff --git a/man/fades.1 b/man/fades.1 index e9631c7..c684c79 100644 --- a/man/fades.1 +++ b/man/fades.1 @@ -14,7 +14,7 @@ fades - A system that automatically handles the virtualenvs in the cases normall [\fB-r\fR][\fB--requirement\fR] [\fB-x\fR][\fB--exec\fR] [\fB-p\fR \fIversion\fR][\fB--python\fR=\fIversion\fR] -[\fB--rm\fR=\fIUUID\fR] +[\fB--rm\fR[=\fIUUID\fR]] [\fB--system-site-packages\fR] [\fB--venv-options\fR=\fIoptions\fR] [\fB--pip-options\fR=\fIoptions\fR] @@ -93,8 +93,8 @@ The dependencies can be indicated in multiple places (in the Python source file, Execute the \fIchild_program\fR in the context of the virtual environment. The child_program, which in this case becomes a mandatory parameter, can be just the executable name (relative to the venv's bin directory) or an absolute path. .TP -.BR --rm " " \fIUUID\fR -Remove a virtual environment by UUID. See \fB--get-venv-dir\fR option to easily find out the UUID. +.BR --rm " " [\fIUUID\fR] +Remove a virtual environment. If a UUID is given, the environment with that UUID is removed (see \fB--where\fR option to easily find out the UUID); if no UUID is given, the environment matching the indicated dependencies is removed (e.g. \fBfades --rm -d django\fR). .TP .BR --system-site-packages "" diff --git a/tests/test_main.py b/tests/test_main.py index fd42154..24591c5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -18,12 +18,15 @@ import os import unittest +import uuid from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch from packaging.requirements import Requirement -from fades import VERSION, FadesError, __version__, helpers, main, parsing, REPO_PYPI, REPO_VCS +from fades import ( + VERSION, FadesError, __version__, file_options, helpers, main, parsing, REPO_PYPI, + REPO_VCS) from tests import create_tempfile @@ -318,6 +321,94 @@ def test_module(self): self.assertEqual(child, child_path) +class RemoveVenvTestCase(unittest.TestCase): + """Check the '--rm' option, removing by uuid or by the indicated dependencies.""" + + def _run(self, cmdline_args, found_venv): + """Run go() with the given cli args, mocking the cache and the venv destruction. + + 'found_venv' is the metadata returned by the cache lookup (or None when nothing matches). + Returns the mocked cache and destroy_venv so the test can check how they were called, + and the parsed arguments. + """ + venvscache = MagicMock() + venvscache.get_venv.return_value = found_venv + with patch('sys.argv', ['fades'] + cmdline_args), \ + patch('fades.main.detect_inside_virtualenv', return_value=False), \ + patch('fades.cache.VEnvsCache', return_value=venvscache), \ + patch('fades.envbuilder.UsageManager'), \ + patch('fades.helpers.get_basedir', return_value=Path('/tmp/fades-test')), \ + patch('fades.file_options.options_from_file', + wraps=file_options.options_from_file) as options_from_file, \ + patch('fades.envbuilder.destroy_venv') as destroy_venv: + result = main.go() + self.assertEqual(result, 0) + # the parsed arguments are updated in place, so these are the final ones + (args,), _ = options_from_file.call_args + return venvscache, destroy_venv, args + + def test_by_uuid_found(self): + venv_uuid = str(uuid.uuid4()) + venvscache, destroy_venv, _ = self._run( + ['--rm', venv_uuid], found_venv={'env_path': '/path/to/venv'}) + venvscache.get_venv.assert_called_once_with(uuid=venv_uuid) + destroy_venv.assert_called_once_with('/path/to/venv', venvscache) + + def test_by_uuid_not_found(self): + venv_uuid = str(uuid.uuid4()) + venvscache, destroy_venv, _ = self._run(['--rm', venv_uuid], found_venv=None) + venvscache.get_venv.assert_called_once_with(uuid=venv_uuid) + destroy_venv.assert_not_called() + + def test_by_uuid_invalid_env_path(self): + _, destroy_venv, _ = self._run( + ['--rm', str(uuid.uuid4())], found_venv={'env_path': None}) + destroy_venv.assert_not_called() + + def _assert_removed_by_deps(self, venvscache, destroy_venv, dep_name): + """Check that the venv was looked up by the indicated dependencies and removed.""" + # the lookup is done by the indicated dependencies, not by uuid + (_, kwargs) = venvscache.get_venv.call_args + self.assertNotIn('uuid', kwargs) + deps = venvscache.get_venv.call_args[0][0] + self.assertIn(dep_name, [req.name for req in deps[REPO_PYPI]]) + destroy_venv.assert_called_once_with('/path/to/venv', venvscache) + + def test_by_dependencies_found(self): + venvscache, destroy_venv, _ = self._run( + ['--rm', '-d', 'foo'], found_venv={'env_path': '/path/to/venv'}) + self._assert_removed_by_deps(venvscache, destroy_venv, 'foo') + + def test_by_dependencies_not_found(self): + venvscache, destroy_venv, _ = self._run(['--rm', '-d', 'foo'], found_venv=None) + destroy_venv.assert_not_called() + + def test_by_dependencies_after_double_dash(self): + script = create_tempfile(self, ['import foo # fades']) + venvscache, destroy_venv, args = self._run( + ['--rm', '--', script], found_venv={'env_path': '/path/to/venv'}) + self._assert_removed_by_deps(venvscache, destroy_venv, 'foo') + self.assertEqual(args.child_program, script) + + def test_by_script_dependencies(self): + # argparse takes the script as the '--rm' value, but it's not a UUID + script = create_tempfile(self, ['import foo # fades']) + venvscache, destroy_venv, args = self._run( + ['--rm', script], found_venv={'env_path': '/path/to/venv'}) + self._assert_removed_by_deps(venvscache, destroy_venv, 'foo') + self.assertEqual(args.child_program, script) + self.assertEqual(args.child_options, []) + + def test_by_script_dependencies_with_options(self): + # the script options must not be taken as the child program + script = create_tempfile(self, ['import foo # fades']) + venvscache, destroy_venv, args = self._run( + ['--rm', script, 'arg1', '--opt'], found_venv={'env_path': '/path/to/venv'}) + self._assert_removed_by_deps(venvscache, destroy_venv, 'foo') + self.assertEqual(args.child_program, script) + self.assertEqual(args.child_options, ['arg1', '--opt']) + + # --------------------------------------- # autoimport tests