Welcome, guest | Sign In | My Account | Store | Cart

WHICH.PY scans through all directories specified in the system %PATH% environment variable, looking for the specified COMMAND(s). It tries to follow the sometimes bizarre rules for Windows command lookup.

Python, 228 lines
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
#!Python27.exe
#-*- coding: utf-8 -*-

#-------------------------------------------------------------------------------
# WHICH.PY
# 
# WHICH.PY scans through all directories specified in the system %PATH%
# environment variable, looking for the specified COMMAND(s). It tries
# to follow the sometimes bizarre rules for Windows command lookup.

# Copyright (c) 2013 by Robert L. Pyron <rpyron+which@gmail.com>
# Distributed under terms of the MIT License: http://opensource.org/licenses/MIT
# 
# TODO:
#   -   Figure out how to automatically update version info (probably related
#       to version control)
#   -   Add option for long listing format: date, time, attributes 
#       (system or hidden), SYMLINK info if appropriate, and file size
#   -   Build executable version.
# 
# HISTORY:
#   Version 0.1
#       -   This is the first version that I am willing to make publically
#           available. 
#       -   Submitted to ActiveState Recipes, 15 August 2013.
# 
#-------------------------------------------------------------------------------

"""Write the full path of COMMAND(s) to standard output.

Usage: %(PROG)s [-e] [-f] [-v] [-h] COMMAND [...]

  -e, --exact   Print exact matches only.
  -f, --first   Print just the first match.
  -v, --version Print version and exit successfully.
  -h, --help    Print help message and exit successfully.

%(PROG)s scans through all directories specified in the system %%PATH%%
environment variable, looking for the specified COMMAND(s). It tries
to follow the sometimes bizarre rules for Windows command lookup.

Copyright (c) 2013 by Robert L. Pyron <rpyron@alum.mit.edu>

"""

#-------------------------------------------------------------------------------

import sys, os, os.path, argparse, traceback

#
# I'm on Windows, so I can arbitrarily force the script name to upper case.
PROG = os.path.basename(sys.argv[0]).upper()

#
# Expand module docstring to get USAGE string
USAGE = __doc__ % {'PROG' : PROG}

#
# Get PATH environment variable, and split it.
PATH = os.environ.get('PATH').split(';')
if True:
    # Clean up the PATH info: A directory may appear in PATH more than 
    # once, but there is no need to scan it twice. Also, since this program 
    # is intended to run on Windows, we will always start in the current 
    # directory. I'm ignoring changes in pathname case, because I'm lazy 
    # and it probably doesn't matter very much.
    tmpDirs = ['.']
    for dir in PATH:
        if dir not in tmpDirs:
                tmpDirs += [dir]
    PATH,tmpDirs = tmpDirs,None

#
# Get PATHEXT environment variable, and split it.
# I'm on Windows, so I can arbitrarily force the extensions to lower case.
PATHEXT = os.environ.get('PATHEXT').lower().split(';')

#
# TODO - fix this
VERSION = '$Id$'
VERSION = '0.1'

#
# These commands are built-in to CMD.EXE under Windows 8.
# Earlier and later versions of Windows may have more (or fewer) built-ins.
# Also, other command-line shells may have a different list.
BUILTINS = ["ASSOC", "BREAK", "BCDEDIT", "CALL", "CD", "CHDIR", "CLS", "COLOR",
			"COPY", "DATE", "DEL", "DIR", "ECHO", "ENDLOCAL", "ERASE", "EXIT", 
			"FOR", "FTYPE", "GOTO", "GRAFTABL", "IF", "MD", "MKDIR", "MKLINK", 
			"MOVE", "PATH", "PAUSE", "POPD", "PROMPT", "PUSHD", "RD", "REM", 
			"REN", "RENAME", "RMDIR", "SET", "SETLOCAL", "SHIFT", "START", 
			"TIME", "TITLE", "TYPE", "VER", "VERIFY", "VOL" ]

#
# Global command-line arguments, to be filled in by parse_args()
args = argparse.Namespace()

#-------------------------------------------------------------------------------

#
# Utility function: split a filename into path,root,extension.
def split_name(filename):
    dirname,basename = os.path.split(filename)
    root,ext = os.path.splitext(basename)
    return dirname,root,ext

#
# Find matches to specified filename in system %PATH%.
def which(COMMAND):
    # Define a convenience exception for early exit from this routine.
    # This is used as a Pythonic equivalent to GOTO.
    class FoundFirstMatch(Exception): 
        pass
    
    try:
        print( COMMAND )
    
        # Check whether this is a built-in command.
        if COMMAND.upper() in BUILTINS:
            print( '    (builtin under CMD.EXE)' )
            # The obvious thing to do at this point would be to return, 
            # because we now know everything we need to know, right? 
            # Think again, buddy. This is Windows we are dealing with.
            if args.firstMatchOnly:
                raise FoundFirstMatch()

        # Split COMMAND into parts.
        specified_directory,specified_command,specified_ext = split_name(COMMAND)

        # In general, we don't want to have a pathname specified as part of
        # the COMMAND. However, I allow the syntax '.\COMMAND' to restrict
        # search to current directory.
        local_currentDirectoryOnly = False
        if specified_directory == '.':
            # Search only in current directory.
            local_currentDirectoryOnly = True
            # Reconstruct COMMAND without path.
            COMMAND = specified_command + specified_ext
        elif specified_directory:
            # Do not include path as part of input name.
            # If you know where it is, why are you calling this program?
            print( '    (please do not specify a path as part of COMMAND)' )
            return

        # I also allow a shortcut to specify exactMatchOnly by appending a dot.
        # For example, "foo.exe." looks for "foo.exe" and nothing else.
        local_exactMatchOnly = args.exactMatchOnly
        if specified_ext == '.':
            local_exactMatchOnly = True
            # Reconstruct COMMAND without extension.
            COMMAND, specified_ext = specified_command, ''

        # Scan through all directories in %PATH%.
        count = 0
        for dir in PATH:
            # Expand environment variables in PATH component
            dir = os.path.expandvars(dir)
            # First, look for an exact match in this directory.
            if specified_ext or local_exactMatchOnly:
                target = os.path.join(dir,COMMAND)
                if os.path.isfile(target):
                    print( '    ' + target )
                    count += 1
                    if args.firstMatchOnly:
                        raise FoundFirstMatch()
                    else:
                        # Move along to next directory in PATH.
                        continue
            # Now, try all extensions listed in PATHEXT.
            if not local_exactMatchOnly:
                for ext in PATHEXT:
                    target = os.path.join(dir,specified_command+ext)
                    if os.path.isfile(target):
                        print( '    ' + target )
                        count += 1
                        if args.firstMatchOnly:
                            raise FoundFirstMatch()
                        else:
                            # Move along to next extension in PATHEXT.
                            continue
        if count == 0:
            print( '    (not found)' )
    
    except FoundFirstMatch:
        pass
    finally:
        print

    return

#-------------------------------------------------------------------------------

#
# Parse command-line arguments
def parse_args():
    """Parse command-line arguments; fill in global variable 'args'."""
    global args
    parser = argparse.ArgumentParser()
    parser.add_argument('-e', '--exact',   help='Print exact matches only.',            action='store_true', dest='exactMatchOnly' )
    parser.add_argument('-f', '--first',   help='Print just the first match.',          action='store_true', dest='firstMatchOnly' )
    parser.add_argument('-v', '--version', help='Print version and exit successfully.', action='version', version=VERSION)
    parser.add_argument('COMMANDS', nargs=argparse.REMAINDER)
    args = parser.parse_args()
    return args

#
# Command-line arguments have already been parsed.
def main (args):
    if not args.COMMANDS:
        print USAGE
        return
    for COMMAND in args.COMMANDS:
        which(COMMAND)

if __name__ == '__main__':
    try:
        args = parse_args()
        main(args)
        sys.exit(0)
    except KeyboardInterrupt, e:    # Ctrl-C
        raise e
    except SystemExit, e:           # sys.exit()
        raise e
    except Exception, e:
        print( 'ERROR, UNEXPECTED EXCEPTION' )
        print( str(e) )
        traceback.print_exc()
        os._exit(1)
DESCRIPTION

WHICH.PY scans through all directories specified in the system %PATH% environment variable, looking for the specified COMMAND(s). It tries to follow the sometimes bizarre rules for Windows command lookup.

USAGE

Write the full path of COMMAND(s) to standard output.

Usage: WHICH [-e] [-f] [-v] [-h] COMMAND [...]

  -e, --exact   Print exact matches only.
  -f, --first   Print just the first match.
  -v, --version Print version and exit successfully.
  -h, --help    Print help message and exit successfully.

WHICH scans through all directories specified in the system %PATH%
environment variable, looking for the specified COMMAND(s). It tries
to follow the sometimes bizarre rules for Windows command lookup.
EASTER EGGS

I provide two shortcuts to restrict the search:

  • In general, we don't want to have a pathname specified as part of the COMMAND. However, I allow the syntax '.\COMMAND' to restrict search to current directory.
  • I also allow a shortcut to specify exactMatchOnly by appending a dot. For example, "foo.exe." looks for "foo.exe" and nothing else.
DISCUSSION

I have often wanted a Windows-specific version of the Unix which command. GNU-derived versions of which are useful, but incomplete. The rules for command lookup under Windows are sometimes bizarre. This is what I have determined through experimentation:

  • If the command name exactly matches a built-in command, the built-in takes precedence.

  • Otherwise CMD.EXE will search for the command starting in the current directory, then in each directory specified by the %PATH% environment table. At each directory in this search:

    • If the command name has an extension:
      • If the command extension matches an extension from the %PATHEXT% environment variable, then CMD.EXE will attempt to execute the file.
      • If the command name extension is associated with a particular program, then that program is invoked, with this file as input.
        • TODO: My program does not handle this situation.
      • Don't give up yet. This is Windows.
    • Finally, each extension from the %PATHEXT% environment variable will be appended in turn to the supplied command name. If the constructed command matches one of the above rules, we have a winner!

Consider this scenario:

  • There is a program named verify.exe, somewhere on your path.
  • Unknown to you, there is also a program named verify.exe.py, at another location on your path.
  • At the command prompt, you type verify. Much to your surprise, CMD.EXE executes the built-in program VERIFY, which is not what you want.
  • You try again: verify.exe. Again to your surprise, CMD.EXE finds and executes verify.exe.py, which is also not what you want.

Granted that's not very likely, but it could happen. This is why WHICH.PY defaults to listing every possible match it finds in the system path.

ALTERNATIVES

The GnuWin32 version of which.exe is useful, but it does not know about CMD.EXE's built-in commands. It also does not know all of the rules for command lookup

WHERE.EXE, available in Windows since Windows Server 2003, does not know about built-in commands. WHERE.EXE also provides too much information, because it does not distinguish between files that are executable and those that are not.

TODO
  • Account for file associations.
  • Use Py2Exe to build an executable binary.
  • Create a GitHub repository for this project.
REQUIRED MODULES
sys os os.path argparse traceback
TAGS
Python Windows commandline utility which

This program has been posted at several locations online:

Copyright © 2013 by Robert L. Pyron (mailto:rpyron+which@gmail.com)

Distributed under terms of the MIT License: http://opensource.org/licenses/MIT