"""Interactive FAQ project. Note that this is not an executable script; it's an importable module. The actual CGI script can be kept minimal; it's appended at the end of this file as a string constant. XXX TO DO XXX User Features TO DO - next/prev/index links in do_show??? - explanation of editing somewhere - embellishments, GIFs, hints, etc. - support adding annotations, too - restrict recent changes to last week (or make it an option) - extended search capabilities XXX Management Features TO DO - username/password for authors - create new sections - rearrange entries - delete entries - freeze entries - send email on changes? - send email on ERRORS! - optional staging of entries until reviewed? (could be done using rcs branches!) - prevent race conditions on nearly simultaneous commits XXX Performance - could cache generated HTML - could speed up searches with a separate index file XXX Code organization TO DO - read section titles from a file (could be a Python file: import faqcustom) - customize rcs command pathnames (and everything else) - make it more generic (so you can create your own FAQ) - more OO structure, e.g. add a class representing one FAQ entry """ # NB for timing purposes, the imports are at the end of this file PASSWORD = "Spam" NAMEPAT = "faq??.???.htp" NAMEREG = "^faq\([0-9][0-9]\)\.\([0-9][0-9][0-9]\)\.htp$" SECTIONS = { "1": "General information and availability", "2": "Python in the real world", "3": "Building Python and Other Known Bugs", "4": "Programming in Python", "5": "Extending Python", "6": "Python's design", "7": "Using Python on non-UNIX platforms", } class FAQServer: def __init__(self): pass def main(self): self.form = cgi.FieldStorage() req = self.req or 'frontpage' try: method = getattr(self, 'do_%s' % req) except AttributeError: print "Unrecognized request type", req else: method() self.epilogue() KEYS = ['req', 'query', 'name', 'text', 'commit', 'title', 'author', 'email', 'log', 'section', 'number', 'add', 'version', 'edit', 'password'] def __getattr__(self, key): if key not in self.KEYS: raise AttributeError try: form = self.form try: item = form[key] except TypeError, msg: raise KeyError, msg, sys.exc_traceback except KeyError: return '' value = self.form[key].value value = string.strip(value) setattr(self, key, value) return value def do_frontpage(self): self.prologue("Python FAQ Wizard (beta test)") print """

Search the FAQ


(Case insensitive regular expressions.)

Disclaimer: these pages are intended to be edited by anyone. Please exercise discretion when editing, don't be rude, etc. """ def do_index(self): self.prologue("Python FAQ Index") names = os.listdir(os.curdir) names.sort() section = None for name in names: headers, text = self.read(name) if headers: title = headers['title'] i = string.find(title, '.') nsec = title[:i] if nsec != section: if section: print """

  • Add new entry (at this point) """ % section section = nsec if SECTIONS.has_key(section): stitle = SECTIONS[section] else: stitle = "" print "

    Section %s. %s

    " % (section, stitle) print " """ % section else: print "No FAQ entries?!?!" def do_show(self): self.prologue("Python FAQ Entry") print "
    " name = self.name headers, text = self.read(name) if not headers: self.error("Invalid file name", name) return self.show(name, headers['title'], text) def do_all(self): import fnmatch, stat self.prologue("The Whole Python FAQ") names = os.listdir(os.curdir) lastmtime = 0 for name in names: if not fnmatch.fnmatch(name, NAMEPAT): continue try: st = os.stat(name) except os.error: continue lastmtime = max(lastmtime, st[stat.ST_MTIME]) if lastmtime: print time.strftime("Last changed on %c %Z", time.localtime(lastmtime)) names.sort() section = None print "
    " for name in names: headers, text = self.read(name) if headers: title = headers['title'] i = string.find(title, '.') nsec = title[:i] if nsec != section: section = nsec if SECTIONS.has_key(section): stitle = SECTIONS[section] else: stitle = "" print "

    Section %s. %s

    " % (section, stitle) print "
    " self.show(name, title, text, edit=(self.edit != 'no')) if not section: print "No FAQ entries?!?!" def do_roulette(self): import whrandom self.prologue("Python FAQ Roulette") print """ Please check the correctness of the entry below. If you find any problems, please edit the entry.


    """ names = os.listdir(os.curdir) while names: name = whrandom.choice(names) headers, text = self.read(name) if headers: self.show(name, headers['title'], text) print "

    Use `Reload' to show another one." break else: names.remove(name) else: print "No FAQ entries?!?!" def do_recent(self): import fnmatch, stat names = os.listdir(os.curdir) now = time.time() list = [] for name in names: if not fnmatch.fnmatch(name, NAMEPAT): continue try: st = os.stat(name) except os.error: continue tuple = (st[stat.ST_MTIME], name) list.append(tuple) list.sort() list.reverse() self.prologue("Python FAQ, Most Recently Modified First") print "


    " n = 0 for (mtime, name) in list: headers, text = self.read(name) if headers and headers.has_key('last-changed-date'): self.show(name, headers['title'], text) n = n+1 if not n: print "No FAQ entries?!?!" def do_query(self): query = self.query if not query: self.error("No query string") return import regex self.prologue("Python FAQ Query Results") p = regex.compile(query, regex.casefold) names = os.listdir(os.curdir) names.sort() print "
    " n = 0 for name in names: headers, text = self.read(name) if headers: title = headers['title'] if p.search(title) >= 0 or p.search(text) >= 0: self.show(name, title, text) n = n+1 if not n: print "No hits." def do_add(self): section = self.section if not section: self.prologue("How to add a new FAQ entry") print """ Go to the FAQ index and click on the "Add new entry" link at the end of the section to which you want to add the entry. """ return try: nsec = string.atoi(section) except ValueError: print "Bad section number", nsec names = os.listdir(os.curdir) max = 0 import regex prog = regex.compile(NAMEREG) for name in names: if prog.match(name) >= 0: s1, s2 = prog.group(1, 2) n1, n2 = string.atoi(s1), string.atoi(s2) if n1 == nsec: if n2 > max: max = n2 if not max: self.error("Can't add new sections yet.") return num = max+1 name = "faq%02d.%03d.htp" % (nsec, num) self.name = name self.add = "yes" self.number = str(num) self.do_edit() def do_delete(self): self.prologue("How to delete a FAQ entry") print """ At the moment, there's no direct way to delete entries. This is because the entry numbers are also their unique identifiers -- it's a bad idea to renumber entries.

    If you really think an entry needs to be deleted, change the title to "(deleted)" and make the body empty (keep the entry number in the title though). """ def do_edit(self): name = self.name headers, text = self.read(name) if not headers: self.error("Invalid file name", name) return self.prologue("Python FAQ Edit Wizard - Edit Form") print 'Click for Help' title = headers['title'] version = self.getversion(name) print "

    " self.showedit(name, title, text) if self.add: print """ """ % (self.add, self.section, self.number) print """

    """ % (name, version) self.show(name, title, text, edit=0) def do_review(self): if self.commit: self.checkin() return name = self.name text = self.text title = self.title headers, oldtext = self.read(name) if not headers: self.error("Invalid file name", name) return if self.author or '@' in self.email or self.password: self.set_cookie(self.author, self.email, self.password) self.prologue("Python FAQ Edit Wizard - Review Form") print 'Click for Help' print "
    " self.show(name, title, text, edit=0) print "
    " if self.password == PASSWORD \ and self.log and self.author and '@' in self.email: print """ Click this button to commit the change.


    """ else: print """ To commit this change, please enter a log message, your name, your email address, and the correct password in the form below.


    """ self.showedit(name, title, text) if self.add: print """ """ % (self.add, self.section, self.number) print """


    """ % (name, self.version) def do_info(self): name = self.name headers, text = self.read(name) if not headers: self.error("Invalid file name", name) return self.prologue("Info for %s" % name) print '
    '
    	p = os.popen("/depot/gnu/plat/bin/rlog -r %s &1" %
    		     self.name)
    	output = p.read()
    	p.close()
    	print cgi.escape(output)
    	print '
    ' print 'View full rcs log' % name def do_rlog(self): name = self.name headers, text = self.read(name) if not headers: self.error("Invalid file name", name) return self.prologue("RCS log for %s" % name) print '
    '
    	p = os.popen("/depot/gnu/plat/bin/rlog %s &1" % self.name)
    	output = p.read()
    	p.close()
    	print cgi.escape(output)
    	print '
    ' def checkin(self): import regsub, time, tempfile name = self.name password = self.password if password != PASSWORD: self.error("Invalid password.") return if not (self.log and self.author and '@' in self.email): self.error("No log message, no author, or invalid email.") return headers, oldtext = self.read(name) if not headers: self.error("Invalid file name", name) return version = self.version curversion = self.getversion(name) if version != curversion: self.error( "Version conflict.", "You edited version %s but current version is %s." % ( version, curversion), """

    The two most common causes of this problem are:

    """, 'Click here to reload the entry and try again.') return text = self.text title = self.title author = self.author email = self.email log = self.log text = regsub.gsub("\r\n", "\n", text) log = regsub.gsub("\r\n", "\n", log) author = string.join(string.split(author)) email = string.join(string.split(email)) title = string.join(string.split(title)) oldtitle = headers['title'] oldtitle = string.join(string.split(oldtitle)) text = string.strip(text) oldtext = string.strip(oldtext) if text == oldtext and title == oldtitle: self.error("No changes.") return # Check that the FAQ entry number didn't change if string.split(title)[:1] != string.split(oldtitle)[:1]: self.error("Don't change the FAQ entry number please.") return remhost = os.environ["REMOTE_HOST"] remaddr = os.environ["REMOTE_ADDR"] try: os.unlink(name + "~") except os.error: pass try: os.rename(name, name + "~") except os.error: pass try: os.unlink(name) except os.error: pass try: f = open(name, "w") except IOError, msg: self.error("Can't open", name, "for writing:", cgi.escape(str(msg))) return now = time.ctime(time.time()) f.write("Title: %s\n" % title) f.write("Last-Changed-Date: %s\n" % now) f.write("Last-Changed-Author: %s\n" % author) f.write("Last-Changed-Email: %s\n" % email) f.write("Last-Changed-Remote-Host: %s\n" % remhost) f.write("Last-Changed-Remote-Address: %s\n" % remaddr) keys = headers.keys() keys.sort() keys.remove('title') for key in keys: if key[:13] != 'last-changed-': f.write("%s: %s\n" % (string.capwords(key, '-'), headers[key])) f.write("\n") f.write(text) f.write("\n") f.close() tfn = tempfile.mktemp() f = open(tfn, "w") f.write("Last-Changed-Date: %s\n" % now) f.write("Last-Changed-Author: %s\n" % author) f.write("Last-Changed-Email: %s\n" % email) f.write("Last-Changed-Remote-Host: %s\n" % remhost) f.write("Last-Changed-Remote-Address: %s\n" % remaddr) f.write("\n") f.write(log) f.write("\n") f.close() # Do this for show() below self.headers = { 'title': title, 'last-changed-date': now, 'last-changed-author': author, 'last-changed-email': email, 'last-changed-remote-host': remhost, 'last-changed-remote-address': remaddr, } p = os.popen(""" /depot/gnu/plat/bin/rcs -l %s &1 /depot/gnu/plat/bin/ci -u %s <%s 2>&1 rm -f %s """ % (name, name, tfn, tfn)) output = p.read() sts = p.close() if not sts: self.set_cookie(author, email, password) self.prologue("Python FAQ Entry Edited") print "


    " self.show(name, title, text) if output: print "
    %s
    " % cgi.escape(output) else: self.error("Python FAQ Entry Commit Failed", "Exit status 0x%04x" % sts) if output: print "
    %s
    " % cgi.escape(output) def set_cookie(self, author, email, password): name = "Python-FAQ-Wizard" value = "%s/%s/%s" % (author, email, password) import urllib value = urllib.quote(value) print "Set-Cookie: %s=%s; path=/cgi-bin/;" % (name, value), import time now = time.time() then = now + 28 * 24 * 3600 gmt = time.gmtime(then) print time.strftime("expires=%a, %d-%b-%x %X GMT", gmt) def get_cookie(self): if not os.environ.has_key('HTTP_COOKIE'): return "", "", "" raw = os.environ['HTTP_COOKIE'] words = map(string.strip, string.split(raw, ';')) cookies = {} for word in words: i = string.find(word, '=') if i >= 0: key, value = word[:i], word[i+1:] cookies[key] = value if not cookies.has_key('Python-FAQ-Wizard'): return "", "", "" value = cookies['Python-FAQ-Wizard'] import urllib value = urllib.unquote(value) words = string.split(value, '/') while len(words) < 3: words.append('') author = string.join(words[:-2], '/') email = words[-2] password = words[-1] return author, email, password def showedit(self, name, title, text): author = self.author email = self.email password = self.password if not author or not email or not password: a, e, p = self.get_cookie() author = author or a email = email or e password = password or p print """ Title:
    """ % ( self.escape(title), cgi.escape(string.strip(text))) print """
    Log message (reason for the change):

    Please provide the following information for logging purposes:
    Name:
    Email:
    Password:
    """ % (self.escape(self.log), self.escape(author), self.escape(email), self.escape(password)) def escape(self, s): import regsub if '&' in s: s = regsub.gsub("&", "&", s) # Must be done first! if '<' in s: s = regsub.gsub("<", "<", s) if '>' in s: s = regsub.gsub(">", ">", s) if '"' in s: s = regsub.gsub('"', """, s) return s def showheaders(self, headers): print "" headers = None def read(self, name): self.headers = None import fnmatch, rfc822 if not fnmatch.fnmatch(name, NAMEPAT): return None, None if self.add: try: fname = "faq%02d.%03d.htp" % (string.atoi(self.section), string.atoi(self.number)) except ValueError: return None, None if fname != name: return None, None headers = {'title': "%s.%s. " % (self.section, self.number)} text = "" else: f = open(name) headers = rfc822.Message(f) text = f.read() f.close() self.headers = headers return headers, text def show(self, name, title, text, edit=1): print "

    %s

    " % cgi.escape(title) pre = 0 for line in string.split(text, '\n'): if not string.strip(line): if pre: print '' pre = 0 else: print '

    ' else: if line[0] not in string.whitespace: if pre: print '' pre = 0 else: if not pre: print '

    '
    			pre = 1
    		if '/' in line or '@' in line:
    		    line = self.translate(line)
    		elif '<' in line or '&' in line:
    		    line = cgi.escape(line)
     		if not pre and '*' in line:
     		    line = self.emphasize(line)
    		print line
    	if pre:
    	    print '
    ' pre = 0 print '

    ' if edit: print """ Edit this entry / Log info """ % (name, name) if self.headers: try: date = self.headers['last-changed-date'] author = self.headers['last-changed-author'] email = self.headers['last-changed-email'] except KeyError: pass else: s = '/ Last changed on %s by %s' print s % (date, email, author) print '

    ' print "


    " def getversion(self, name): p = os.popen("/depot/gnu/plat/bin/rlog -h %s &1" % name) head = "*new*" while 1: line = p.readline() if not line: break if line[:5] == 'head:': head = string.strip(line[5:]) p.close() return head def prologue(self, title): title = cgi.escape(title) print ''' %s

    %s

    ''' % (title, title) def error(self, *messages): self.prologue("Python FAQ error") print "Sorry, an error occurred:
    " for message in messages: print message, print def epilogue(self): if self.edit == 'no': global wanttime wanttime = 0 else: print '''


    Python home / FAQ Wizard home / Feedback to GvR ''' print ''' ''' translate_prog = None def translate(self, text): if not self.translate_prog: import regex url = '\(http\|ftp\)://[^ \t\r\n]*' email = '\<[-a-zA-Z0-9._]+@[-a-zA-Z0-9._]+' self.translate_prog = prog = regex.compile(url + "\|" + email) else: prog = self.translate_prog i = 0 list = [] while 1: j = prog.search(text, i) if j < 0: break list.append(cgi.escape(text[i:j])) i = j url = prog.group(0) while url[-1] in ");:,.?'\"": url = url[:-1] url = self.escape(url) if ':' in url: repl = '%s' % (url, url) else: repl = '<%s>' % (url, url) list.append(repl) i = i + len(url) j = len(text) list.append(cgi.escape(text[i:j])) return string.join(list, '') emphasize_prog = None def emphasize(self, line): import regsub if not self.emphasize_prog: import regex pat = "\*\([a-zA-Z]+\)\*" self.emphasize_prog = prog = regex.compile(pat) else: prog = self.emphasize_prog return regsub.gsub(prog, "\\1", line) print "Content-type: text/html" dt = 0 wanttime = 0 try: import time t1 = time.time() import cgi, string, os, sys x = FAQServer() x.main() t2 = time.time() dt = t2-t1 wanttime = 1 except: print "\n
    Sorry, an error occurred" cgi.print_exception() if wanttime: print "
    (running time = %s seconds)" % str(round(dt, 3)) # The following bootstrap script must be placed in cgi-bin/faq.py: BOOTSTRAP = """ #! /usr/local/bin/python FAQDIR = "/usr/people/guido/python/FAQ" import os, sys os.chdir(FAQDIR) sys.path.insert(0, os.curdir) import faqmain """