1. Jeremy Kloth
  2. cpython-buildbot-amd64

Commits

Brian Curtin  committed 0fe7439 Draft

Fix #444582. Add shutil.which function for finding programs on the system path.

  • Participants
  • Parent commits c6e0ca1
  • Branches default

Comments (0)

Files changed (4)

File Doc/library/shutil.rst

View file
 
    .. versionadded:: 3.3
 
+.. function:: which(cmd, mode=os.F_OK | os.X_OK, path=None)
+
+   Return the full path to an executable which would be run if the given
+   *cmd* was called. If no *cmd* would be called, return ``None``.
+
+   *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`.
+
+   On Windows, the current directory is always prepended to the *path*
+   whether or not you use the default or provide your own, which
+   is the behavior the command shell uses when finding executables.
+   Additionaly, when finding the *cmd* in the *path*, the
+   ``PATHEXT`` environment variable is checked. For example, if you
+   call ``shutil.which("python")``, :func:`which` will search
+   ``PATHEXT`` to know that it should look for ``python.exe`` within
+   the *path* directories.
+
+      >>> print(shutil.which("python"))
+      'c:\\python33\\python.exe'
+
+   .. versionadded:: 3.3
 
 .. exception:: Error
 

File Lib/shutil.py

View file
            "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):
             lines = size.lines
 
     return os.terminal_size((columns, lines))
+
+def which(cmd, mode=os.F_OK | os.X_OK, path=None):
+    """Given a file, mode, and a path string, return the path whichs 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(fn, mode):
+        if (os.path.exists(fn) and os.access(fn, mode)
+            and not os.path.isdir(fn)):
+            return True
+        return False
+
+    # Short circuit. If we're given a full path which matches the mode
+    # and it exists, we're done here.
+    if _access_check(cmd, mode):
+        return cmd
+
+    path = (path or os.environ.get("PATH", os.defpath)).split(os.pathsep)
+
+    if sys.platform == "win32":
+        # The current directory takes precedence on Windows.
+        if 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 = [cmd for ext in pathext if cmd.lower().endswith(ext.lower())]
+        # If it does match, only test that one, otherwise we have to try others.
+        files = [cmd + ext.lower() for ext in pathext] if not matches else [cmd]
+    else:
+        # On other platforms you don't have things like PATHEXT to tell you
+        # what file suffixes are executable, so just pass on cmd as-is.
+        files = [cmd]
+
+    seen = set()
+    for dir in path:
+        dir = os.path.normcase(os.path.abspath(dir))
+        if not dir in seen:
+            seen.add(dir)
+            for thefile in files:
+                name = os.path.join(dir, thefile)
+                if _access_check(name, mode):
+                    return name
+    return None
+

File Lib/test/test_shutil.py

View file
         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")
+        os.chmod(self.temp_file.name, stat.S_IXUSR)
+        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)
+
+    @unittest.skipUnless(sys.platform == "win32",
+                         "pathext check is Windows-only")
+    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)
+
+
 class TestMove(unittest.TestCase):
 
     def setUp(self):
 
 def test_main():
     support.run_unittest(TestShutil, TestMove, TestCopyFile,
-                         TermsizeTests)
+                         TermsizeTests, TestWhich)
 
 if __name__ == '__main__':
     test_main()

File Misc/NEWS

View file
 Library
 -------
 
+- Issue #444582: Add shutil.which, for finding programs on the system path.
+  Original patch by Erik Demaine, with later iterations by Jan Killian
+  and Brian Curtin.
+
 - Issue #14837: SSL errors now have ``library`` and ``reason`` attributes
   describing precisely what happened and in which OpenSSL submodule.  The
   str() of a SSLError is also enhanced accordingly.