#!/opt/bin/python # Automates batch operations for svn and Java. Only tested on Mac OS X and # Linux (sliderule.csse); almost certainly won't work on Windows. # # by Curt Clifton, December 2008 from __future__ import with_statement import sys import os from string import Template from subprocess import Popen, PIPE, STDOUT # Nearly constants menuWidth = 72 rootURL = 'http://svn.csse.rose-hulman.edu/repos' testClassesListing = 'unitTestMap.txt' lockFileName = '.batchEclipseLock' batchLogFileName = 'batchLogFile.txt' # ---------------------------------------------------------------- # Term parameters # stripped from each repo name to get short name: repoNamePrefix = 'csse120-200930-' repoFileName = repoNamePrefix + 'repos.txt' # For Testing: repoFileName = 'littleRepos.txt' scriptDir = sys.path[0] # Other global variables projectName = '' testClasses = '' workingDir = os.getcwd() menuItems = [] repos = [] with open(scriptDir + '/' + testClassesListing, 'r') as f: projects = map(lambda l: l.strip().split(',')[0], f.readlines()) projects = filter(lambda x: x != '' and not x.startswith('#'), projects) def setRepos(withPrinting=False): global repos repos = [] with open(scriptDir + '/' + repoFileName, 'r') as f: if withPrinting: print 'Repositories to use:' for l in f: line = l.strip() repos.append(line) print line else: repos = f.readlines() repos = map(lambda l: l.strip(), repos) setRepos() # ---------------------------------------------------------------- def truncateMiddle(d, length): """Returns a truncated version of d by omitting characters in the middle if d is longer then length.""" splitLen = length / 2 if len(d) < length: return d else: return d[:splitLen] + '...' + d[-(length-3-splitLen):] # ---------------------------------------------------------------- def displayMenu(prompt, items, allowOthers = False): """ Displays a numbered menu where each item is an item of the sequence items. Returns a tuple consisting of the chosen item number (or None if other text is enter) and the text of the choice. Presents the given prompt. Continues presenting the prompt until the user picks an item from the list. If allowOthers is True, then also lets user enter a non-numeric string which is the text returned to the caller. Otherwise the element of items corresponding to the entered number is returned. """ for i,t in enumerate(items): print "%2d - %s" % (i, t) print choice = '' while choice == '': inputValue = raw_input(prompt).strip() try: inputValue = int(inputValue) choice = items[inputValue] except ValueError: if allowOthers: choice = inputValue inputValue = None else: print '\a*** you must choose a number from the list above ***' choice = '' except IndexError: print '\a*** only numbers in the list above are valid ***' return inputValue, choice # ---------------------------------------------------------------- def forEachRepo(command, msg='', create=False, withCapture=False, suppressOutput=False): """Iterates over each repository executing the given command. The command is executed with the current working directory set to the local working directory. The command is a template string. Within the template string: ${rootURL} is replaced with the root URL of the repository ${repo} is replaced with the full repository name ${proj} is replaced with the project name ${shortName} is replaced with the short repository name, that is the full repository name with the course/term prefix removed ${msg} is replaced with the msg argument ${scriptDir} is replaced with the script directory ${cwd} is replaced with the working directory ${testClasses} is replaced with a space-delimited list of the fully qualified names of the JUnit test classes msg is the replacement for the ${msg} placeholder in the command create specifies whether the local project subdirectory should be created withCapture specifies whether the output (stdout and stderr) of the command should be captured to batchLogFileName, lines of captured data are prefixed with the short repository name, a colon, and a space suppressOutput specified whether the output of the command should be suppressed on the console """ global projectName replacements = {} replacements['rootURL'] = rootURL replacements['proj'] = projectName replacements['msg'] = "'" + msg + "'" replacements['scriptDir'] = scriptDir replacements['cwd'] = workingDir replacements['testClasses'] = testClasses commandTemplate = Template(command) if command.find('${testClasses}') >= 0 and len(testClasses) == 0: print '\a\n*** No test classes listed for project "' + projectName + '" ***\n' return # Creates project specific subdirectory if requested if create: if os.access(projectName,os.F_OK): print '\a\n*** Local project subdirectory already exists ***\n' return else: os.mkdir(projectName) if not os.access(projectName,os.F_OK): print '\a\n*** Unable to create local project subdirectory ***\n' return # Verifies project subdirectory exists if not os.access(projectName,os.F_OK): print '\a\n*** Unable to access local project subdirectory ***\n' return # Functional goodness for printing if suppressOutput: def printHelper(x): pass else: def printHelper(x): print x # Iterates over the repositories if withCapture: captureFile = open(batchLogFileName, 'w') for repo in repos: replacements['repo'] = repo replacements['shortName'] = repo.replace(repoNamePrefix, '') cmd = commandTemplate.substitute(replacements) printHelper('-*' * (menuWidth / 2)) shortNameLength = len(replacements['shortName']) padRepeat = (menuWidth - shortNameLength) / 2 / 4 printHelper('--> ' * padRepeat + replacements['shortName'].upper() + ' <--' * padRepeat) printHelper(truncateMiddle(cmd, menuWidth)) os.chdir(projectName) p = Popen(cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=STDOUT, close_fds=True) (cmdInput, cmdOutput) = (p.stdin, p.stdout) outputText = cmdOutput.readlines() cmdInput.close() cmdOutput.close() for line in outputText: printHelper(line.strip()) if withCapture: captureFile.write(replacements['shortName']) captureFile.write(': ') captureFile.write(line) os.chdir('..') if withCapture: captureFile.close() # ---------------------------------------------------------------- # Creates a dictionary of menu items and functions to execute: def checkout(): forEachRepo('svn checkout ${rootURL}/${repo}/${proj} ${shortName}', create=True) return False menuItems.append(('Checkout from SVN',checkout)) def update(): forEachRepo('svn update ${shortName}') return False menuItems.append(('Update from SVN',update)) def commit(): if os.access(projectName + '/' + lockFileName,os.F_OK): print '\a\n*** Remove Eclipse metadata before committing. ***\n' return False message = raw_input('Enter the commit message: ').strip().replace("'", '"') forEachRepo('svn commit ${shortName} -m ${msg}', msg=message) return False menuItems.append(('Commit to SVN',commit)) def checkLog(): forEachRepo('svn log --limit 5 ${shortName}') return False menuItems.append(('Get data on last 5 commits',checkLog)) def generateCommitTimeList(): forEachRepo('svn info ${shortName} | tail -n 4 | head -n 3', withCapture=True, suppressOutput=True) # Processes batchLogFileName to create sorted list # Output format: # : committed by on # - or - # : unexpected data format results = [] # list of pairs of (commitDate or None, formatted output line) unexpected = True shortName = 'unknown' with open(batchLogFileName, 'r') as f: for line in f: # Extracts shortName and svn info from batch file line line = line.strip() spl = line.split(':',1) if len(spl) == 2: name, rest = spl rest = rest.lstrip() else: # Handles unexpected data (poorly) name = spl[0] rest = spl[0] # Handles possibilities for rest if rest.startswith('Last Changed Author: '): # Start of block unexpected = False shortName = name committer = rest[21:] commitDate = 'unknown' elif rest.startswith('Last Changed Date: '): # End of block if name == shortName: commitDate = rest[19:] outLine = '%s: committed by %s on %s' % (shortName, committer, commitDate) results.append((commitDate, outLine)) else: unexpected = True elif rest.startswith('Last Changed Rev: '): # Ignored data if name != shortName: unexpected = True else: # Unexpected data unexpected = True # Records unexpected data as soon as it's detected if unexpected: results.append((None, '%s: unexpected data format' % (shortName))) unexpected = False shortName = 'unknown' # Sorts and prints results results.sort(key = lambda x: x[0]) print "\nMost recent commit info, in chronological order:" for k,v in results: print v print '\n' return False menuItems.append(('Generate list of commits, sorted by commit time',generateCommitTimeList)) def compile(): forEachRepo("mkdir ${shortName}/bin") forEachRepo("javac -cp '${scriptDir}/junit-4.5.jar:${shortName}/bin' -d '${shortName}/bin' `find ${shortName} -type f -name '*.java'`") return False menuItems.append(('Compile student code',compile)) def runTests(): forEachRepo("cd ${shortName} && java -cp '${scriptDir}/junit-4.5.jar:bin' org.junit.runner.JUnitCore ${testClasses}") return False menuItems.append(('Run JUnit tests',runTests)) def convertToEclipse(): # Change project names to short names by editing the Eclipse metadata forEachRepo("sed -i~ 's:${proj}:${shortName}:' '${shortName}/.project'") # Add lock file to block commits lockFile = projectName + '/' + lockFileName if os.access(projectName,os.F_OK): os.system('touch %s' % (lockFile)) if not os.access(lockFile, os.F_OK): print '\a\n*** DANGER: Unable to create lock file. Do NOT commit! ***\n' else: print """\a\a\a In Eclipse, use Import to batch import all of the student projects. Be sure to NOT copy the projects into your workspace or you won't be able to add comments to their code. WARNING!!! DO NOT COMMIT TO SVN FROM WITHIN ECLIPSE. YOU COULD WEDGE THE STUDENT'S VERSION OF THEIR PROJECT. """ return False menuItems.append(('Add Eclipse metadata (allows importing all projects into Eclipse)',convertToEclipse)) def convertFromEclipse(): # Revert project names within .project files forEachRepo("sed -i~ 's:${shortName}:${proj}:' '${shortName}/.project'") # Remove lock file to enable commits lockFile = projectName + '/' + lockFileName if os.access(lockFile,os.F_OK): os.system('rm -f %s' % (lockFile)) if os.access(lockFile,os.F_OK): print '\a\n*** Unable to remove lock file: %s ***\n' % (lockFileName) return False return False menuItems.append(('Remove added Eclipse metadata (required before committing)',convertFromEclipse)) def zipForDownload(): if os.access(projectName,os.F_OK): os.system('zip -r %s.zip %s' % (projectName, projectName)) if not os.access(projectName + '.zip',os.F_OK): print '\a\n*** Unable to create zip file ***\n' return False menuItems.append(('Zip local project subdirectory for download', zipForDownload)) def deleteProjects(): if os.access(projectName,os.F_OK): os.system('rm -Rf %s' % (projectName)) if os.access(projectName,os.F_OK): print '\a\n*** Unable to delete local project subdirectory ***\n' return False menuItems.append(('Delete local project subdirectory (and your copies of student projects)',deleteProjects)) def setProjectNameFromInput(): global projectName, testClasses projectPrompt = 'Enter the Eclipse project name (or number from list above): ' n, projectName = displayMenu(projectPrompt, projects, allowOthers=True) testClasses = '' with open(scriptDir + '/' + testClassesListing, 'r') as f: for l in f: if l.startswith(projectName): lSplit = l.strip().split(',') if len(lSplit) != 2: print '\a\n*** Bad format in %s for project "%s" ***\n' % (testClassesListing, projectName) else: testClasses = lSplit[1] if testClasses == '': print '\a\n*** No unit tests listed for project "%s" ***\n' % (projectName) return False menuItems.append(('Enter another project name',setProjectNameFromInput)) def changeRepoListFile(): global repoFileName, repos # Presents list of .txt files in script directory fileList = os.listdir(scriptDir) fileList = filter(lambda x: x.endswith('.txt'), fileList) fileList.sort() prompt = """ Enter number of text file that lists the repositories in which you are interested: """ n, repoFileName = displayMenu(prompt, fileList) setRepos(True) return False menuItems.append(('Change to a different set of repositories',changeRepoListFile)) def exitProgram(): print "Buh-bye. " return True menuItems.append(('Quit',exitProgram)) # ---------------------------------------------------------------- # Display prompt for project name print """ Welcome to the CSSE 230 batch SVN and JUnit script. by Curt Clifton, Dec. 2008 """ setProjectNameFromInput() done = False while not done: print '-' * menuWidth print 'Project Name:', projectName print 'Working Dir.:', truncateMiddle(workingDir, menuWidth - 14) print 'Repositories:', repoFileName print 'Test Classes:', truncateMiddle(testClasses, menuWidth - 14) print '-' * menuWidth choice, t = displayMenu('\nEnter the number of your choice: ', map(lambda y: y[0], menuItems)) try: fn = menuItems[choice][1] done = fn() except Exception, e: raise e