File: class/Extras/Other/PyLotto/pylotto.py
#!/usr/bin/python """ ===================================================================== PyLotto: select students of a class at random to receive copies of a book, working around various security constraints. I use this to give away free copies of my Python books in some of the classes I teach. In order to work around the security restrictions at some sites, this script tries to be general. It can run the lottery in 3 different modes, selected by command-line arguments in local console mode, or by URL query parameters in remote web mode: 1) to select from emails, 2) to select from names in a file created by web page submits, 3) to select from names typed into a local text file manually. Accordingly, students enter the lottery in 1 of 3 specific ways, depending on security at a given site; by: (1) Sending an email message to "PP4E@learning-python.com" with subject "PYLOTTO" and their From address. (2) Submitting the form at "http://learning-python.com/pylotto.html" with their name, which adds them to a file on a remote web server (an equivalent GET-style URL described ahead does the same work). (3) Appearing in a manually-created text file of names on the script's own machine named "pylotto-players.txt" (physically, the script and file may be local, or remote via SSH). Winners are then picked at random from all players, by either scanning the email inbox and parsing emails, or reading the names file created manually or by the web page submits. Results can be displayed in console text or CGI web reply page format. In a bit more detail, the lottery script itself may be pysically run: --in a local console to select from emails, if POP access is supported at the site; --remotely on the web as a CGI script to select from emails or a file, if only HTTP web access is available; --in a local console to select from a local file if neither web nor email are allowed. These contexts translate to selection and display modes configured by command-line options: "pylotto.py -show console -find email" --runs locally from emails "pylotto.py -show cgi -find email" --runs remotely from emails "pylotto.py -show cgi -find file" --runs remotely from a file "pylotto.py -show console -find file" --runs locally from a file "pylotto.py -form player" --adds player name to file As described ahead, in remote CGI web mode, equivalent query parameters in GET-style URLs are simply translated to command-lines: "http://learning-python.com/cgi/pylotto.py?-show=console&-find=email" "http://learning-python.com/cgi/pylotto.py?-show=cgi&-find=file" "http://learning-python.com/cgi/pylotto.py?-form=player" To enable CGI usage modes, set the script's "RunAsCGI" variable to True when installing. A variety of admin and test modes are also available; see this script's __main__ logic for more details. See Chapter 13 for more on email tools, Chapter 15 for more on CGI concepts, and Part 2 for hints on adding a tkinter GUI for sign-up or reporting (I've already worked on this more than I intended to!). --------------------------------------------------------------------- NEW: CGI and local file modes Because the original email sign-up scheme requires email access which is not always available at client sites, I've completely rewritten this script to also select from a flat file of names, which may be created by either web form submits or by manual edits. This script can now be run locally or remotely, and as both a command-line script or a CGI script via a web URL. In fact, it can now run locally on a local file as a last resort: some pathologically secure sites may disallow instructor email access, but also student email sends, and even student web form submits. In the worst case, names are typed into and selected from a simple text file. Command-line arguments configure the script to select from either a file or emails, and to display its results in either console or CGI web reply format. In CGI mode, this script can be run both to sign-up players, and to run its lottery logic. In both roles, its URL may be run from a web page form's action, manually typed in a web browser, sent with Python's urllib in a script, and so on. For instance, to run the lottery in CGI mode, a URL of the following form is submitted by the instructor (only! -- this would work if run by students, but the results it reports to them are not used): http://learning-python.com/cgi/pylotto.py?-show=cgi&-find=file and submitting the "pylotto.html" form to sign up players is equivalent to sending a GET-style URL like the following: http://learning-python.com/cgi/pylotto.py?-form=playername These URL invocation forms also have command-line equivalents when run in a local console (as discussed ahead, queries map to these, which can also be run from a console window to test): pylotto.py -show cgi -find file pylotto.py -form playername A simple flat file is used to record sign-ups, with locks to handle possibly concurrent updates to the players file. --------------------------------------------------------------------- NEW: 2.X/3.X portability: The original CGI option assumed your web server ran Python 3.X and a separate 2.X version was coded. I've also now updated this single script to run under either 3.X, or 2.X for older installs (search for "OnPython3" to see the mods). My ISP account is still running Python 2.4 for default accounts (!). Portability impacted print operations, Unicode decoding of email text for 3.1 parsing, encoding to bytes for os.write, shiny new tools like sys.version_info field names that aren't backward compatible, and some library module imports (e.g., 3.X's email.*, io.*). --------------------------------------------------------------------- NEW: use cgi.escape() for player names in CGI mode: Crucially, the script also now runs player names through the cgi.escape() call when in CGI display mode, else the "<..>" brackets in emails can throw its results diplay off: player email address could be truncated in the reply page. --------------------------------------------------------------------- NEW: translating query parameters to command-lines in CGI mode When run in CGI mode, the script now translates query parameters to command-line arguments, for compatibility with console usage mode. For instance, the invocation URLs: http://www.learning-python.com/cgi/pylotto.py?-show=cgi&-find=file http://www.learning-python.com/cgi/pylotto.py?-show=console&-find=email http://www.learning-python.com/cgi/pylotto.py?-form=playername http://www.learning-python.com/cgi/pylotto.py?-reset=1 are translated into the following equivalent command-lines when they reach the web server and CGI script: pylotto.py -show cgi -find file # pick from file, show HTML pylotto.py -show console -find email # pick emails, show text [1] pylotto.py -form playername # sign-up a player pylotto.py -reset 1 # '1' is ignored here Per the CGI spec, some web servers might pass through command-line arguments automatically when the query sting (after the "?") does not contain an "=" character (e.g, "pylotto.py?-reset"), but the server at my ISP does not: it simply adds all text after "?" to the query-string value. Run calls to cgi.print_arguments() and cgi.print_environ() to see how your server fares; on mine, all text after '?' always appears in QUERY_STRING, not on sys.argv. Could instead have two top-level __main__ scripts for console/web with a common module, but that seems prone to redundancy problems. [1] Footnote: as implied by these URLs, you can show results in either cgi (HTML text) or console (plain text) form when running in remote CGI mode with script variable RunAsCGI == True; this mode prints the required CGI header lines and performs query-string -> command-line translation. However, player names are CGI-escaped only in -show cgi mode, not for -show console, so cgi is essentially required to display email addresses when running in remote CGI mode to display in a web browser (though -show console plain text makes more sense if you wish to grab the reply with urllib for display in a tkinter GUI...). ===================================================================== """ import poplib, email, random, getpass, pprint, sys, time, cgi, os RunAsCGI = False # True: urlparms->sys.argv, cgi hdr Drawings = 1 # how many players to select Signup = 'PP4E@learning-python.com' # pick-from-email mode parameters Subject = 'PYLOTTO' Server = 'pop.secureserver.net' Filename = 'pylotto-players.txt' # pick-from-file mode parameter OnPython3 = sys.version_info[0] == 3 # some py 2.X can't use .major == 3! def printN(*args): """ needed only if > 1 arg to print, else print(x) works same in 2.X and 3.X; ignores 3.X keyword-only print options: see Learning Python 4E for a version that handles these too; can't use from __future__ for 3.X print until Python 2.6; """ if OnPython3: eval('print(*args)') # invalid syntax in 2.X else: text = ' '.join([str(x) for x in args]) # "print()" is empty tuple in 2.X print(text) class FindEmails: """ find and remove signup emails, mixed with a shower class; py3.1 email requires decoding mail text to str (3.2 may not); messsage_from_string(s) == email.parser.Parser().parsestr(s); later removes duplicate email addresses, but may not be enough; """ hastop = True def findPlayers(self, signup=Signup, subject=Subject, server=Server): password = self.popPassword() players = [] server = poplib.POP3(server) server.user(signup) server.pass_(password) self.trace(server.getwelcome()) self.trace(server.list()) try: msgcount, msgbytes = server.stat() for i in range(msgcount): self.trace('message %s of %s...' % (i+1, msgcount)) if not self.hastop: hdr, msgbytes, octets = server.retr(i+1) # get full text else: hdr, msgbytes, octets = server.top(i+1, 0) # headers only if OnPython3: msglines = [line.decode('utf-8') for line in msgbytes] # 3.1: to str else: # 3.2: optional msglines = msgbytes # 2.X: just str msgtext = '\n'.join(msglines) msgobj = email.message_from_string(msgtext) # parse text if msgobj['Subject'].upper() == subject: players.append(msgobj['From']) server.dele(i+1) # del on quit finally: server.quit() # be sure to unlock mailbox on exit return players class FindFile: """ select from a local file of names, mixed with a shower class; may be run on local client or remote server, in console or CGI modes; run as CGI on server to select from file created by web form or manual; run on local machine if no email access, and CGI web form page unusable; """ def findPlayers(self, filename=Filename): players = [] for line in open(filename): players.append(line.rstrip()) return players # or: return [line.rstrip() for line in open(filename)] class ShowConsole: """ display in a console window, running locally or remotely mix with finder that may select from a names file or emails """ def run(self): print('Running in console...') players = self.findPlayers() print('\n%s\nPlayers:\n' % ('=' * 80)) for player in players: print(player) time.sleep(0.5) winners = self.pickWinners(players) print('\n%s\nWinners:\n' % ('=' * 80)) #pprint.pprint(winners) for winner in winners: print(winner) def popPassword(self): return getpass.getpass('pop email password?') def trace(self, *args, **kargs): printN(*args) class ShowCGI: """ display as CCI web page result, in browser, urllib, console mix with finder that may select from a names file or emails done: run pprint.pprint() output through cgi.escape() -- this is crucial when emails are used, due to their <..> brackets; original naive display: pprint.pprint(players) intermediate - but no io module in python 2.X: buffer = io.StringIO() pprint.pprint(players, buffer) print(cgi.escape(buffer.getvalue())) """ def run(self): #print('Content-type: text/html\n') # header already printed print('<HTML>') print('<TITLE>PyLotto</TITLE>') print('<BODY><H1>PyLotto Results</H1>') print('<H2>Players:</H2>') print('<P><PRE>') players = self.findPlayers() for player in players: print(cgi.escape(player)) print('</PRE></P><H2>Winners:</H2>') print('<P><PRE>') winners = self.pickWinners(players) for winner in winners: print(cgi.escape(winner)) print('</PRE></P></BODY></HTML>') def popPassword(self): # assume file return open('pylotto.pswd').readline().rstrip() # secure me! def trace(*args, **kargs): pass class Picker: """ choose winners at random, mixed in with shower and finder note: set.pop() is defined to remove an arbitrary set item too, but I'd rather rely on the random module explicitly here to be sure that this is fair ("arbitrary" may be arbitrary); """ def pickWinners(self, players, drawings=Drawings): players = list(set(players)) # remove duplicates, probably winners = [] for i in range(drawings): if not players: break else: drawn = random.choice(players) players.remove(drawn) winners.append(drawn) return winners def sendTestPlayers(ccplayer=False): """ send a few test emails, run local or remote via URL callable with -test and during interactive testing adds to the names file on the script's machine too; """ players = ['"Book support" <lutz@rmi.net>', # new: try various formats 'lutz@learning-python.com', 'the book <PP4E@learning-python.com>'] try: import smtplib, email.utils, email.message # email.* fails in Py 2.4 sendserver = 'smtpout.secureserver.net' for player in players: msgobj = email.message.Message() msgobj['From'] = player msgobj['To'] = Signup msgobj['Subject'] = Subject msgobj['Date'] = email.utils.formatdate() msgobj.set_payload('Signing up for lotto...\n') print('Connecting...') rcpts = [Signup] if ccplayer: rcpts.append(player) server = smtplib.SMTP(sendserver) failed = server.sendmail(player, rcpts, str(msgobj)) server.quit() assert not failed print(str(msgobj)) except: pass # smpt not supported here? older 2.X email lib? # also add to names file for testing file = open(Filename, 'a') for player in players: file.write(player + '\n') file.close() def resetFile(filename=Filename): open(filename, 'w').close() # empty the players names file print('Players file cleared. ') def resetEmails(): print('Run main logic to clear emails, with [-find email -show S].') def testLotto(): """ test all 4 main lotto show/find combos: console|cgi * email|file mixes methods from classes per modes just like runLotto() below; """ for shower in ShowConsole, ShowCGI: for finder in FindFile, FindEmails: printN('*' * 80, shower, finder) sendTestPlayers() time.sleep(10) # a guess class PyLotto(shower, finder, Picker): pass PyLotto().run() resetFile() def runLotto(): """ main logic: run the lottery in the show/find modes given in script's cmdline args (default modes = email/console), mixing together selectable run mode classes plus Picker; """ showModes = dict(console=ShowConsole, cgi=ShowCGI) findModes = dict(email=FindEmails, file=FindFile) config = dict(show='console', find='email') # [-show S] [-find F] for arg in config: try: config[arg] = sys.argv[sys.argv.index('-' + arg) + 1] except: pass shower, finder = showModes[config['show']], findModes[config['find']] class PyLotto(shower, finder, Picker): pass PyLotto().run() def handleFormSubmit(filename=Filename): """ the input form or URL's query parameters have already been parsed here, so we can treat CGI inoputs as simple command-line arguments: see RunAsCGI logic in __main__; opens file for appending, with exclusive access lock to support concurrent updates in web mode (close unlocks); """ if not os.path.exists(filename): open(filename, 'w').close() try: player = sys.argv[sys.argv.index('-form') + 1] fd = os.open(filename, os.O_EXCL | os.O_APPEND | os.O_WRONLY) line = (player + '\n').encode() os.write(fd, line) # 3.X: encode to bytes, no-op on 2.X os.close(fd) except: player = '...Error!\n%s\n%s' % sys.exc_info()[:2] if RunAsCGI: # assume run as a web server: html reply print('<HTML>') print('<TITLE>PyLotto</TITLE>') print('<BODY><H1>PyLotto Sign-up</H1>') print('<P>') print('Signed up: %s' % player) print('</P></BODY></HTML>') else: # may be run from console with -form cmdline arg print('Signed up: %s' % player) def initAsCGI(): """ in CGI mode: translate URL query parameters to command-line arguments; cmdline args not passed through on my ISP when run remotely via URL; includes both switches as well as form input field for sign-ups; emit CGI reply hdr now too: may wrap console or html result display; """ print('Content-type: text/html\n') # emit reply header now sys.stderr = sys.stdout # display any Python errors urlparams = cgi.FieldStorage() # parse, xlate params for key in urlparams.keys(): sys.argv.extend([key, urlparams[key].value]) #cgi.print_environ() #cgi.print_arguments() if __name__ == '__main__': usage1 = 'pylotto.py [-send | -reset | -test | -form player | [-show S] [-find F] ]' usage2 = '(S = console | cgi, F = email | file, defaults: console, email)' # web: translate URL params, emit header if RunAsCGI: initAsCGI() # changes sys.argv # run admin modes if '-send' in sys.argv: sendTestPlayers() # send test emails/file lines elif '-reset' in sys.argv: resetFile() # clear state (as possible) resetEmails() elif '-test' in sys.argv: testLotto() # test all lotto mode combinations elif '-form' in sys.argv: handleFormSubmit() # save submitted player in CGI mode # run main logic elif '-show' in sys.argv or '-find' in sys.argv: runLotto() # run lotto with selected modes else: print('Usage:\n%s\n\t%s' % (usage1, usage2))