diff --git a/Doc/library/shutil.rst b/Doc/library/shutil.rst --- a/Doc/library/shutil.rst +++ b/Doc/library/shutil.rst @@ -234,6 +234,25 @@ .. versionadded:: 3.3 +.. function:: which(file, mode=os.F_OK | os.X_OK, path=None) + + Yield full paths to executables which would be run if the given + *file* command was called. + + *mode* is a permission mask passed a to :func:`os.access`, by default + determining if the file exists and executable. + + When no *path* is specified, the results of :func:`os.environ` are + used, returning either the "PATH" value or a fallback of :attr:`os.defpath`. + + If you only wish to receive the first executable on the path, you can + pass :func:`which` to :func:`next`. + + >>> path = next(shutil.which("python")) + >>> print(path) + c:\python33\python.exe + + .. versionadded: 3.3 .. exception:: Error diff --git a/Lib/shutil.py b/Lib/shutil.py --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -36,7 +36,7 @@ "register_archive_format", "unregister_archive_format", "get_unpack_formats", "register_unpack_format", "unregister_unpack_format", "unpack_archive", - "ignore_patterns", "chown"] + "ignore_patterns", "chown", "which"] # disk_usage is added later, if available on the platform class Error(EnvironmentError): @@ -961,3 +961,45 @@ lines = size.lines return os.terminal_size((columns, lines)) + +def which(file, mode=os.F_OK | os.X_OK, path=None): + """Given a file, mode, and a path string, yield full paths which conform + to the given mode on the path.""" + # Check that a given file can be accessed with the correct mode. + # Additionally check that `file` is not a directory, as on Windows + # directories pass the os.access check. + def access_check(file, mode): + if (os.path.exists(file) and os.access(file, mode) + and not os.path.isdir(file)): + return True + return False + + # Short circuit. If we're given a full path which matches the mode + # and it exists, we're done here. The `all` flag has no effect. + if access_check(file, mode): + yield file + + path = (path or os.environ.get("PATH", os.defpath)).split(os.pathsep) + if sys.platform == "win32" and not os.curdir in path: + path.insert(0, os.curdir) + + # PATHEXT is necessary to check on Windows. + pathext = os.environ.get("PATHEXT", "").split(os.pathsep) + + # See if the given file matches any of the expected path extensions. + # This will allow us to short circuit when given "python.exe". + matches = [file for ext in pathext if file.lower().endswith(ext.lower())] + # If it does match, only test that one, otherwise we have to try others. + files = [file + ext.lower() for ext in pathext] if not matches else [file] + + seen = set() + for dir in path: + dir = os.path.normcase(os.path.abspath(dir)) + if not dir in seen: + seen.add(dir) + base = os.path.join(dir, file) + for thefile in files: + name = os.path.join(dir, thefile) + if access_check(name, mode): + yield name + diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1128,6 +1128,63 @@ self.assertEqual(['foo'], os.listdir(rv)) +class TestWhich(unittest.TestCase): + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + # Give the temp_file an ".exe" suffix for all. + # It's needed on Windows and not harmful on other platforms. + self.temp_file = tempfile.NamedTemporaryFile(dir=self.temp_dir, + suffix=".exe") + self.addCleanup(self.temp_file.close) + self.dir, self.file = os.path.split(self.temp_file.name) + + def test_basic(self): + # Given an EXE in a directory, it should be returned. + rv = shutil.which(self.file, path=self.dir) + self.assertEqual(rv, self.temp_file.name) + + def test_full_path_short_circuit(self): + # When given the fully qualified path to an executable that exists, + # it should be returned. + rv = shutil.which(self.temp_file.name, path=self.temp_dir) + self.assertEqual(self.temp_file.name, rv) + + def test_non_matching_mode(self): + # Set the file read-only and ask for writeable files. + os.chmod(self.temp_file.name, stat.S_IREAD) + rv = shutil.which(self.file, path=self.dir, mode=os.W_OK) + self.assertIsNone(rv) + + def test_nonexistent_file(self): + # Return None when no matching executable file is found on the path. + rv = shutil.which("foo.exe", path=self.dir) + self.assertIsNone(rv) + + def test_pathext_checking(self): + # Ask for the file without the ".exe" extension, then ensure that + # it gets found properly with the extension. + rv = shutil.which(self.temp_file.name[:-4], path=self.dir) + self.assertEqual(self.temp_file.name, rv) + + def test_many_executables(self): + # Create several directories containing the same file, then check + # that the all=True yields all of the relevant files. + name = "testing.exe" + dirs = [] + for i in range(5): + dirs.append(tempfile.mkdtemp()) + + files = [] + for dir in dirs: + with open(os.path.join(dir, name), "w"): + pass + + rv = shutil.which(name, path=os.pathsep.join(dirs), all=True) + self.assertSequenceEqual(list(rv), + [os.path.join(dir, name) for dir in dirs]) + + class TestMove(unittest.TestCase): def setUp(self):