#!/usr/bin/env python # twander - Wander around the file system # Copyright (c) 2002 TundraWare Inc. All Rights Reserved. PROGNAME = "twander" RCSID = "$Id: twander.py,v 1.80 2002/11/24 22:46:18 tundra Exp $" VERSION = RCSID.split()[2] #----------------------------------------------------------# # Imports # #----------------------------------------------------------# import getopt import os from socket import getfqdn import sys import time from Tkinter import * from tkMessageBox import showerror, showwarning from tkSimpleDialog import askstring # Imports conditional on OS # Set OS type - this allows us to trigger OS-specific code # where needed. OSNAME = os.name if OSNAME == 'posix': import grp import pwd #----------------------------------------------------------# # Variables User Might Change # #----------------------------------------------------------# ##### # Defaults ##### ##### # Key Assignments ##### # General Program Commands KEYPRESS = '<KeyPress>' # Any keypress (for commands) QUITPROG = '<Escape>' # Quit the program READCONF = '<Control-r>' # Re-read the configuration file REFRESH = '<Control-l>' # Refresh screen TOGDETAIL = '<Control-t>' # Toggle detail view # Directory Navigation CHANGEDIR = '<Control-x>' # Enter a new path DIRHOME = '<Control-h>' # Goto $HOME DIRBACK = '<Control-b>' # Goto previous directory DIRSTART = '<Control-s>' # Goto starting directory DIRUP = '<BackSpace>' # Go up one directory level # Selection Keys SELNEXT = '<Control-n>' # Select next item SELPREV = '<Control-p>' # Select previous item SELEND = '<Control-e>' # Select bottom item SELTOP = '<Control-a>' # Select top item SELKEY = '<Control-space>' # Select item w/keyboard SELMOUSE = '<Double-ButtonRelease-1>' # Select item w/mouse ##### # Default Directories & Files ##### # Default startup directory STARTDIR = "." + os.sep HOME = os.getenv("HOME") or STARTDIR ##### # Program Constants ##### HEIGHT = 25 WIDTH = 90 ##### # Colors ##### BCOLOR = "black" FCOLOR = "green" ##### # Fonts ##### FNAME = "Courier" FSZ = 12 FWT = "bold" #------------------- Nothing Below Here Should Need Changing ------------------# #----------------------------------------------------------# # Constants & Literals # #----------------------------------------------------------# ##### # Booleans ##### # Don't need to define TRUE & FALSE - they are defined in the Tkinter module ##### # Defaults ##### DETAILVIEW = TRUE # File details on WARN = TRUE # Warnings on ##### # Constants ##### # General constants KB = 1024 # 1 KB constant MB = KB * KB # 1 MB constant GB = MB * KB # 1 GB constant HOSTNAME = getfqdn() # Full name of this host REFRESHINT = 5000 # Interval (ms) for automatic refresh # Stat-related # Permissions ST_PERMIT = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"] # Special file type lookup ST_SPECIALS = {"01":"p", "02":"c", "04":"d", "06":"b", "10":"-", "12":"l", "14":"s"} # String used to separate symlink entry from its real path SYMPTR = " -> " ##### # General Literals ##### PSEP = os.sep # Character separating path components ##### # Configuration File Related Literals ##### CONF = "" # Config file user selected with -c option CMDKEY = r'&' # Command key delimiter COMMENT = r"#" # Comment character DIRNAME = r'[DIRECTORY]' # Substitution field in config files FILE = r'[FILE]' # Substitution field in config files FILES = r'[FILES]' # Ditto ENVIRO = r'$' # Introduces environment variables #----------------------------------------------------------# # Prompts, & Application Strings # #----------------------------------------------------------# ##### # Error, Information, & Warning Messages ##### # Errors eBADROOT = " %s Is Not A Directory" eDIRRD = "Cannot Open Directory : %s --- Check Permissions." eDUPKEY = "Duplicate Key In Configuration File Found In Entry: \'%s\'" eERROR = "ERROR" eINITDIRBAD = "Cannot Open Starting Directory : %s - Check Permissions - ABORTING!." eNOENV = "Configuration File References Undefined Environment Variable: %s" eOPEN = "Cannot Open File: %s" eTOOMANY = "You Can Only Specify One Starting Directory." # Prompts pCHPATH = "Change Path" pENPATH = "Enter New Path Desired:" # Warnings wCMDKEY = "Configuration File Entry For: \'%s\' Has No Command Key Defined." wCONFOPEN = "Cannot Open Configuration File:\n%s\n\n%s" wNOCMDS = "Running With No Commands Defined!" wSYMBACK = " Symbolic Link %s Points Back To Own Directory" wWARN = "WARNING" ##### # Usage Information ##### uTable = [PROGNAME + " " + VERSION + " - Copyright 2002, TundraWare Inc., All Rights Reserved\n", "usage: " + PROGNAME + " [-bcfhnsvwxy] [startdir] where,\n", " startdir name of directory in which to begin (default: current dir)", " -b color background color (default: black)", " -c file name of configuration file (default: $HOME/." + PROGNAME + " or PROGDIR/." + PROGNAME + ")", " -f color foreground color (default: green)", " -h print this help information", " -n name name of font to use (default: courier)", " -q quiet mode - no warnings (default: warnings on)", " -s size size of font to use (default: 12)", " -v print detailed version information", " -w wght weight/style of font to use (default: bold)", " -x width window width (default: 60)", " -y height window height (default: 25)", ] #---------------------------Code Begins Here----------------------------------# #----------------------------------------------------------# # General Support Functions # #----------------------------------------------------------# ##### # Print An Error Message ##### def ErrMsg(emsg): showerror(PROGNAME + " " + VERSION + " " + eERROR, emsg) # End of 'ErrMsg()' ##### # Convert A File Size Into Equivalent String With Scaling # Files under 1 MB show actual length # Files < 1 MB < 1 GB shown in KB # Files 1 GB or greater, shown in MB ##### def FileLength(flen): if flen >= GB: flen = str(flen/MB) + "m" elif flen >= MB: flen = str(flen/KB) + "k" else: flen = str(flen) return flen # End of 'FileLength()' ##### # Parse & Process The Configuraton File # This is called once at program start time # and again any time someone hits the READCONF key # while the program is running. ##### def ParseConfFile(event): global CONF, UI # If user specified a config file, try that # Otherwise use HOME == either $HOME or ./ if not CONF: CONF = os.path.join(HOME, "." + PROGNAME) try: cf = open(CONF) except: WrnMsg(wCONFOPEN % (CONF, wNOCMDS)) UI.rcfile = {} return # Successful open of config file - Begin processing it # Cleanout any old UI.rcfile = {} # Process and massage the configuration file for line in cf.read().splitlines(): # Lex for comment token and discard until EOL # A line beginning with the comment token is thus # turned into a blank line, which is discarded. idx = line.find(COMMENT) if idx > -1: # found a comment character line = line[:idx] # Anything which gets through the next conditional # must be a non-blank line - i.e., Configuration information # we care about, so process it and save for future use. if line != "": fields = line.split() for x in range(1,len(fields)): # Process environment variables if fields[x][0] == ENVIRO: envval = os.getenv(fields[x][1:]) if not envval: # Environment variable not defined ErrMsg(eNOENV % fields[x]) sys.exit(1) else: fields[x] = envval # Get command key value and store in dictionary keypos = fields[0].find(CMDKEY) # Look for key delimiter # No delimiter or delimiter at end of string if (keypos < 0) or (CMDKEY == fields[0][-1]): WrnMsg(wCMDKEY % fields[0]) key = fields[0] # Use whole word as index # Found legit delimiter, so use it as # dictionary index - mapped to lower case so # command keys are case insensitive. else: key = fields[0][keypos+1].lower() if key in UI.rcfile: # This is a Python 2.2 or later idiom ErrMsg(eDUPKEY % fields[0]) # Duplicate key found cf.close() sys.exit(1) # Save command name and command using key as index UI.rcfile[key] = ["".join(fields[0].split(CMDKEY)), " ".join(fields[1:]) ] cf.close() # End of 'ParseConfFile()' ##### # Print Usage Information ##### def Usage(): ustring ="" for x in uTable: print x # End of 'Usage()' ##### # Print A Warning Message ##### def WrnMsg(wmsg): if WARN: showwarning(PROGNAME + " " + VERSION + " " + wWARN, wmsg) # End of 'WrnMsg()' #----------------------------------------------------------# # GUI Definition # #----------------------------------------------------------# ##### # Enacapsulate the UI in a class ##### class twanderUI: def __init__(self, root): # Setup the visual elements self.hSB = Scrollbar(root, orient=HORIZONTAL) self.vSB = Scrollbar(root, orient=VERTICAL) self.DirList = Listbox(root, foreground = FCOLOR, background = BCOLOR, font=(FNAME, FSZ, FWT), selectmode=EXTENDED, exportselection=0, xscrollcommand=self.hSB.set, yscrollcommand=self.vSB.set, height = HEIGHT, width = WIDTH, ) # Make them visible by packing self.hSB.config(command=self.DirList.xview) self.hSB.pack(side=BOTTOM, fill=X) self.vSB.config(command=self.DirList.yview) self.vSB.pack(side=RIGHT, fill=Y) self.DirList.pack(side=LEFT, fill=BOTH, expand=1) ##### # Bind the relevant event handlers ##### # General Program Commands # Bind handler for individual keystrokes self.DirList.bind(KEYPRESS, KeystrokeHandler) # Bind handler for "Quit Program" self.DirList.bind(QUITPROG, KeyQuitProg) # Bind handler of "Read Config File" self.DirList.bind(READCONF, ParseConfFile) # Bind handler of "Refresh Screen" self.DirList.bind(REFRESH, RefreshDirList) # Bind handler of "Toggle Detail" self.DirList.bind(TOGDETAIL, KeyToggleDetail) # Directory Navigation # Bind handler for "Change Directory" self.DirList.bind(CHANGEDIR, ChangeDir) # Bind handler for "Home Dir" self.DirList.bind(DIRHOME, KeyHomeDir) # Bind handler for "Previous Dir" self.DirList.bind(DIRBACK, KeyBackDir) # Bind handler for "Starting Dir" self.DirList.bind(DIRSTART, KeyStartDir) # Bind handler for "Up Dir" self.DirList.bind(DIRUP, KeyUpDir) # Selection Keys # Bind handler for "Next Item" self.DirList.bind(SELNEXT, KeySelNext) # Bind handler for "Previous Item" self.DirList.bind(SELPREV, KeySelPrev) # Bind handler for "First Item" self.DirList.bind(SELTOP, KeySelTop) # Bind handler for "Last Item" self.DirList.bind(SELEND, KeySelEnd) # Bind handler for "Item Select" self.DirList.bind(SELKEY, DirListHandler) # Bind handler for "Mouse Select" self.DirList.bind(SELMOUSE, DirListHandler) # Give the listbox focus so it gets keystrokes self.DirList.focus() # End if method 'twanderUI.__init__()' ##### # Support periodic polling to make sure widget stays # in sync with reality of current directory. ##### def poll(self): RefreshDirList() self.DirList.after(REFRESHINT, self.poll) # End of method 'twanderUI.poll()' ##### # Return tuple of all selected items ##### def AllSelection(self): sellist = [] for entry in self.DirList.curselection(): sellist.append(self.DirList.get(entry)[UI.NameFirst:].split(SYMPTR)[0]) return sellist # End of method 'twanderUI.AllSelection()' ##### # Return name of currently selected item ##### def LastInSelection(self): index = self.DirList.curselection() if index: return self.DirList.get(index[-1])[UI.NameFirst:].split(SYMPTR)[0] else: return "" # End of method 'twanderUI.LastInSelection()' ##### # Update title bar with most current information ##### def UpdateTitle(self, mainwin): mainwin.title(PROGNAME + " " + VERSION + " " + HOSTNAME + ": "+ UI.CurrentDir + " Total Files: " + str(UI.DirList.size()) + " Total Size: " + FileLength(UI.TotalSize)) # End of method 'twanderUI.UpdateTitle()' # End of class definition, 'twanderUI' #----------------------------------------------------------# # Handler Functions # #----------------------------------------------------------# #--------------- General Program Commands -----------------# ##### # Event Handler: Individual Keystrokes ##### def KeystrokeHandler(event): # If the key pressed is a command key, # get its associated string and # execute the command. cmd = UI.rcfile.get(event.char.lower(), ["",""])[1] # cmd == null means no matching command key - do nothing # Otherwise, replace config tokens with actual file/dir names if cmd: # Replace runtime-determined tokens cmd = cmd.replace(FILE, UI.LastInSelection()) fstring = "" for selected in UI.AllSelection(): fstring += selected + " " cmd = cmd.replace(FILES, fstring) cmd = cmd.replace(DIRNAME, UI.CurrentDir) # Actually execute the command os.system(cmd) # end of 'KeystrokeHandler()' ##### # Event Handler: Program Quit ##### def KeyQuitProg(event): sys.exit() # End of 'KeyQuitProg()' ##### # Event Handler: Toggle Detail View ##### def KeyToggleDetail(event): global DETAILVIEW DETAILVIEW = not DETAILVIEW RefreshDirList(event) # End of 'KeyToggleDetail()' #------------------- Directory Navigation -----------------# ##### # Event Handler: Change Directory/Path #### def ChangeDir(event): newpath = askstring(pCHPATH, pENPATH) if newpath: LoadDirList(newpath) KeySelTop(event) UI.DirList.focus() # End of 'ChangeDir()' ##### # Event Handler: Goto $HOME ##### def KeyHomeDir(event): if HOME: LoadDirList(HOME) else: LoadDirList(STARTDIR) # End of 'KeyHomeDir()' ##### # Event Handler: Move To Previous Directory ##### def KeyBackDir(event): # Move to last directory visited, if any - inhibit this from # being placed on the directory traversal stack if UI.LastDir: LoadDirList(UI.LastDir.pop(), save=FALSE) # No previous directory else: pass # End of 'KeyBackDir()' ##### # Event Handler: Go Back to Initial Directory ##### def KeyStartDir(event): global STARTDIR LoadDirList(STARTDIR) # End of 'KeyStartDir()' ##### # Event Handler: Move up one directory ##### def KeyUpDir(event): # Move up one directory level unless we're already at the root if UI.CurrentDir != os.path.abspath(PSEP): LoadDirList(UI.CurrentDir + "..") # End of 'KeyUpDir()' #---------------------- Selection Keys ---------------------# ##### # Event Handler: Select Next Item ##### def KeySelNext(event): next = UI.DirList.index(ACTIVE) + 1 SetSelection((str(next),), next) # End of 'KeySelNext()' ##### # Event Handler: Select Previous Item ##### def KeySelPrev(event): prev = UI.DirList.index(ACTIVE) - 1 SetSelection((str(prev),), prev) # End of 'KeySelPrev()' ##### # Event Handler: Select Last Item ##### def KeySelEnd(event): # Get current number of items in listbox sz = UI.DirList.size() # And setup to last item accordingly SetSelection((str(sz),), sz) # End of 'KeySelEnd()' ##### # Event Handler: Select First Item ##### def KeySelTop(event): SetSelection(('0',),0) # End of 'KeySelTop()' ##### # Process Current Selection ##### def DirListHandler(event): SAVE = TRUE # Get current selection. If none, just return, otherwise process selected = UI.LastInSelection() if not selected: return # If selection is a directory, move there and list contents. if os.path.isdir(os.path.join(UI.CurrentDir, selected)): # If we're on Unix, don't follow symlinks pointing back to themselves if OSNAME == 'posix' and os.path.samefile(UI.CurrentDir, UI.CurrentDir + selected): WrnMsg(wSYMBACK % (UI.CurrentDir + selected)) return # We don't push this selection on the stack if # we are at root directory and user presses '..' if (selected == '..') and (UI.CurrentDir == os.path.abspath(PSEP)): SAVE = FALSE # Build full path name selected = os.path.join(os.path.abspath(UI.CurrentDir), selected) # Convert ending ".." into canonical path if selected.endswith(".."): selected = PSEP.join(selected.split(PSEP)[:-2]) # Need to end the directory string with a path # separator character so that subsequent navigation # will work when we hit the root directory of the file # system. In the case of Unix, this means that # if we ended up at the root directory, we'll just # get "/". In the case of Win32, we will get # "DRIVE:/". selected += PSEP # Load UI with new directory LoadDirList(selected, save=SAVE) # And always force selection of first item there. # This guarantees a selection in the new # directory context, so subsequent commands # won't try to operate on an item selected in a # previous directory KeySelTop(event) # File selected with a double-click # If we're running Win32, use OS association to try and run it. elif OSNAME == 'nt': os.startfile(os.path.join(os.path.abspath(UI.CurrentDir), selected)) # Have to update the window title because selection changed UI.UpdateTitle(UIroot) # End of 'DirListHandler()' ##### # Load UI With Selected Directory ##### def LoadDirList(newdir, save=TRUE): # Get path into canonical form newdir = os.path.abspath(newdir) # Make sure newdir properly terminated if newdir[-1] != PSEP: newdir += PSEP # Check right now to see if we can read # the directory. If not, at least we # haven't screwed up the widget's current # contents or program state. try: contents = BuildDirList(newdir) except: # If CurrentDir set, we're still there: error w/ recovery if UI.CurrentDir: ErrMsg(eDIRRD % newdir) return # If not, we failed on the initial directory: error & abort else: ErrMsg(eINITDIRBAD % newdir) sys.exit(1) # Push last directory visited onto the visited stack # Do not do this if we've been told not to OR if # what we're about to save is the same as the top # of the stack OR if the current directory is "" # If there is anything on the stack, see if last element # matches what we're about to put there. if UI.LastDir and UI.LastDir[-1] == UI.CurrentDir: save = FALSE if save and UI.CurrentDir: UI.LastDir.append(UI.CurrentDir) # And select new directory to visit UI.CurrentDir = newdir # Clear out the old contents UI.DirList.delete(0,END) # Load new directory contents into UI for x in contents: UI.DirList.insert(END, x) # And update the title to reflect changes UI.UpdateTitle(UIroot) # Indicate that we've just entered a new directory # so RefreshDirList does the right thing UI.NewDirEntered = TRUE # End of 'LoadDirList(): ##### # Return Ordered List Of Directories & Files For Current Root ##### def BuildDirList(currentdir): global UI dList, fList = [], [] # Walk the directory separate subdirs and files for file in os.listdir(currentdir): if os.path.isdir(os.path.join(currentdir, file)): dList.append(file + PSEP) else: fList.append(file) dList.sort() # Entry to move up one directory is always first, # no matter what the sort. This is necessary because # OSs like Win32 like to use '$' in file names which # sorts before "." dList.insert(0, ".." + PSEP) fList.sort() # For both choices below, we have to set the UI.NameFirst # value. This tells other handlers where in a given # selection the actual name of the file can be found. # This is necessary because we may be selecting a from # a detailed view and need to know where in that view # the file name lives. It is not good enoch to just # split() the string and use the [-1] entry because # we may be dealing with a file which has spaces in its # name. # If user has not requested detailed display, we're done if not DETAILVIEW: UI.NameFirst = 0 return dList + fList # Detailed display requested, do the work all = dList + fList detlist = [] UI.TotalSize = 0 for index in range(len(all)): try: fn = os.path.join(currentdir, all[index]) if fn[-1] == PSEP: fn =fn[:-1] stinfo = os.lstat(fn) detlist.append("") # Mode - 1st get into octal string mode = stinfo[0] modestr = str("%06o" % mode) # Set the permission bits mode = "" for x in [-3, -2, -1]: mode += ST_PERMIT[int(modestr[x])] # Deal with the special permissions sp = int(modestr[-4]) # Sticky Bit if 1 & sp: if mode[-1] == "x": mode = mode[:-1] + "t" else: mode = mode[:-1] + "T" # Setgid Bit if 2 & sp: if mode[-4] == "x": mode = mode[:-4] + "g" + mode[-3:] else: mode = mode[:-4] + "G" + mode[-3:] # Setuid Bit if 4 & sp: if mode[-7] == "x": mode = mode[:-7] + "g" + mode[-6:] else: mode = mode[:-7] + "G" + mode[-6:] # Pickup the special file types mode = ST_SPECIALS.get(modestr[0:2], "?") + mode detlist[index] += str(mode) + (11 - len(str(mode))) * " " # Number of links to entry detlist[index] += str(stinfo[3]) + ( 5 - len(str(stinfo[3]))) * " " # Get up to 11 characters of owner and group names on unix if OSNAME == 'posix': owner = pwd.getpwuid(stinfo[4])[0][:11] group = grp.getgrgid(stinfo[5])[0][:11] # Default names for all other OSs else: owner = OSNAME + 'user' group = OSNAME + 'group' # Add them to the detail detlist[index] += owner + (12 - len(owner)) * " " detlist[index] += group + (12 - len(group)) * " " # Length flen = FileLength(stinfo[6]) UI.TotalSize += stinfo[6] detlist[index] += flen + (11 - len(flen)) * " " # Ctime ftime = " ".join(time.ctime(stinfo[9]).split()[1:]) detlist[index] += ftime + (21 - len(ftime)) * " " # Set the index into beginning of file name UI.NameFirst = len(detlist[index]) # File name detlist[index] += all[index] # Include symlink details as necessary if detlist[index][0] == 'l': # If the symlink points to a file # in the same directory, just show # the filename and not the whole path f = os.path.realpath(currentdir + all[index]) r = os.path.split(f) if r[0] == currentdir[:-1]: f = r[1] detlist[index] += SYMPTR + f except: detlist[index] = all[index] return detlist # End of 'BuildDirList()' ##### # Refresh contents of directory listing to stay in sync with reality ##### def RefreshDirList(*args): # Get current selection and active sellist = UI.DirList.curselection() active = UI.DirList.index(ACTIVE) # Save current scroll positions xs = UI.hSB.get() ys = UI.vSB.get() # Get new directory listing newlist = BuildDirList(UI.CurrentDir) # Clean out old listbox contents UI.DirList.delete(0,END) # Save the new directory information UI.DirList.insert(0, *newlist) # First refresh after a new directory # entered needs to force selection and # active to 0. This works around some # strange Tkinter behavior. if UI.NewDirEntered: KeySelTop(args) UI.NewDirEntered = FALSE # Otherwise restore selections else: SetSelection(sellist, active) # Restore scroll positions UI.DirList.xview(MOVETO, xs[0]) UI.DirList.yview(MOVETO, ys[0]) # End of 'RefreshDirList() ##### # Set a particular selection, w/bounds checking ##### def SetSelection(selection, active): # Clear all current selection(s) UI.DirList.selection_clear(0, END) # Get current maximum index maxindex = UI.DirList.size() - 1 # And bounds check/adjust if active > maxindex: active = maxindex # Set desired selected items, if any if selection: for entry in selection: UI.DirList.select_set(entry) UI.DirList.see(selection[-1]) # Now set the active entry UI.DirList.activate(active) # End of 'SetSelection()' #----------------------------------------------------------# # Program Entry Point # #----------------------------------------------------------# # Command line processing try: opts, args = getopt.getopt(sys.argv[1:], '-b:c:f:hn:qs:vw:x:y:') except getopt.GetoptError: Usage() sys.exit(1) # Parse command line for opt, val in opts: if opt == "-b": BCOLOR = val if opt == "-c": CONF = val if opt == "-f": FCOLOR = val if opt == "-h": Usage() sys.exit(0) if opt == "-n": FNAME = val if opt == "-q": WARN = FALSE if opt == "-s": FSZ = val if opt == "-v": print RCSID sys.exit(0) if opt == "-w": FWT = val if opt == "-x": WIDTH = val if opt == "-y": HEIGHT = val # Create an instance of the UI UIroot = Tk() UI = twanderUI(UIroot) # Make the Tk window the topmost in the Z stack. # 'Gotta do this or Win32 will not return input # focus to our program after a startup warning # display. UIroot.tkraise() ##### # Setup global UI variables ##### # Figure out where to start # Program can only have 0 or 1 arguments # Make sure any startdir argument is legit if len(args) > 1: ErrMsg(eTOOMANY) sys.exit(1) if len(args) == 1: STARTDIR = args[0] if not os.path.isdir(STARTDIR): ErrMsg(eBADROOT % STARTDIR) sys.exit(1) # Get starting directory into canonical form STARTDIR = os.path.abspath(STARTDIR) # Parse the and store configuration file, if any ParseConfFile(None) # Initialize directory stack UI.LastDir = [] # And current location UI.CurrentDir = "" # And the flag indicating new directory selected UI.NewDirEntered = FALSE # Initialize the UI directory listing LoadDirList(STARTDIR) KeySelTop(None) # And start the periodic polling of the widget UI.poll() # Run the program interface UIroot.mainloop()