Repository created. 0.2
authorwking <wking@loki.(none)>
Thu, 28 Aug 2008 22:48:43 +0000 (18:48 -0400)
committerwking <wking@loki.(none)>
Thu, 28 Aug 2008 22:48:43 +0000 (18:48 -0400)
18 files changed:
README [new file with mode: 0644]
certgen.py [new file with mode: 0644]
chem_db.py [new file with mode: 0644]
chem_web.py [new file with mode: 0755]
chem_web_secure.py [new file with mode: 0755]
docs/Makefile [new file with mode: 0644]
docs/README [new file with mode: 0644]
docs/contact.tex [new file with mode: 0644]
docs/door_template.tex [new file with mode: 0644]
docs/inventory_template.tex [new file with mode: 0644]
docs/mp/Makefile [new file with mode: 0644]
docs/mp/NFPA_c.mp [new file with mode: 0644]
docs/mp/README [new file with mode: 0644]
docs/mp/gen_NFPA.sh [new file with mode: 0755]
docs/mp/sample.tex [new file with mode: 0644]
examples/inventory.db [new file with mode: 0644]
mk_simple_certs.py [new file with mode: 0644]
text_db.py [new file with mode: 0644]

diff --git a/README b/README
new file mode 100644 (file)
index 0000000..69ba196
--- /dev/null
+++ b/README
@@ -0,0 +1,104 @@
+Web, python, and command-line interfaces for managing a chemical inventory.
+
+COMMANDS
+
+The web interface (chem_web.py) is a web-daemon using web.py and it's build in htmlserver.
+Standard command for starting the daemon
+ $ chem_web.py -a 192.168.1.2:55555
+Standard command for stopping the daemon
+ $ chem_web.py --stop
+
+From the command line, you can validate CAS#s (and possibly other fields) with
+ $ python chem_db.py -f current/inventory.db -V
+
+
+DATABASE FORMAT
+
+text_db.py provides the python interface to this format.  The basic
+idea was to produce and use files which were M$-Excel-compatible,
+since future users might not want to maintain this interface.  A brief
+example inventory.db file is included in the examples directory.
+
+Tab-delimited ('\t') fields
+Endline-delimited ('\n') records
+The first line is the header (starts with a pound sign '#').
+The header should be a tab-delimited list of short field names.
+If the second line also begins with a pound sign,
+it contains a tab-delimiteded list of long field names.
+Blank lines are ignored.
+
+The fields H, F, R, and S are the NFPA Health, Fire, Reactivity, and Special Hazards (NFPA diamond).
+
+Blue: Health Hazard
+   0  Hazard no greater than ordinary material
+   1  May cause irritation; minimal residual injury
+   2  Intense or prolonged exposure may cause incapacitation;
+      Residual injury may occur if not treated
+   3  Exposure could cause serious injury even if treated
+   4  Exposure may cause death
+Red: Fire Hazard
+   0  Will not burn
+   1  Must be preheated for ignition; flashpoint above 200°F (93°C)
+   2  Must be moderately heated for ignition, flashpoint above 100°F (38°C)
+   3  Ignition may occur under most ambient conditions,
+      Flashpoint below 100°F (38°C)
+   4  Extremely flammable and will readily disperse through air under standard
+      conditions, flashpoint below 73°F (23°C)
+Reactivity hazards:
+   0  Stable
+   1  May become unstable at elevated temperatures and pressures.
+      May be mildly water reactive
+   2  Unstable; may undergo violent decomposition, but will not detonate.
+      May form explosive mixtures with water
+   3  Detonates with strong ignition source
+   4  Readily detonates
+Special Hazards have the following codes:
+ OX   strong oxidizer
+ -W-  water reactive
+ SA   simple ascphyxiants
+      (The only gases for which this symbol is permitted are nitrogen, helium,
+       neon, argon, krypton, and xenon..)
+Non-official Special Hazard codes:
+ ACID  acid
+ ALK   base
+ COR   corrosive
+ BIO   Biohazard
+ POI   Poison
+ CRY   Cyrogenic
+
+
+CONTENTS
+
+README      this file
+text_db.py  python interface to the above db format
+chem_db.py  chem-inventory specific functionality such as
+             * CAS # validation,
+              * a Material Saftey Data Sheet (MSDS) manager,
+             * document-generation drivers, and
+             * a simple command line interface.
+current     store the current database file.
+backup      store previous (timestamped) database files.
+           You may want to clean this out occasionally.
+MSDS        store Material Saftey Data Sheets by database index number
+docs        latex (and metapost) sources for document generation.
+chem_web.py daemonized web bindings to chem_web.py
+ssl         Secure Socket Layer (SSL) key and certificate generation tests.
+
+
+GENERATED FILES AND DIRECTORIES
+
+templates     quasi-HTML templates for pages generated chem_web.py
+chem_web.pid  store the Process ID (PID) for a daemonized chem_web.py process
+chem_web.log  log chem_web activity (maintained between runs, so remove periodically)
+
+
+TODO
+
+Security:
+I can create my own self-signed certificate.
+Protects against eavesdropping, but not man-in-the-middle attacks.
+Solution for now: hide from external world, trust everyone inside, backup before any change.
+
+
+Print columns in a particular order from the database with 
+ $ awk -F '    ' '{print $1, "\t", $9, "\t", , "\t", $2}' current/inventory.db | less
diff --git a/certgen.py b/certgen.py
new file mode 100644 (file)
index 0000000..04b9d5b
--- /dev/null
@@ -0,0 +1,80 @@
+#
+# certgen.py
+#
+# Copyright (C) Martin Sjogren and AB Strakt 2001, All rights reserved
+#
+# $Id: certgen.py,v 1.2 2004/07/22 12:01:25 martin Exp $
+#
+"""
+Certificate generation module.
+"""
+
+from OpenSSL import crypto
+
+TYPE_RSA = crypto.TYPE_RSA
+TYPE_DSA = crypto.TYPE_DSA
+
+def createKeyPair(type, bits):
+    """
+    Create a public/private key pair.
+
+    Arguments: type - Key type, must be one of TYPE_RSA and TYPE_DSA
+               bits - Number of bits to use in the key
+    Returns:   The public/private key pair in a PKey object
+    """
+    pkey = crypto.PKey()
+    pkey.generate_key(type, bits)
+    return pkey
+
+def createCertRequest(pkey, digest="md5", **name):
+    """
+    Create a certificate request.
+
+    Arguments: pkey   - The key to associate with the request
+               digest - Digestion method to use for signing, default is md5
+               **name - The name of the subject of the request, possible
+                        arguments are:
+                          C     - Country name
+                          ST    - State or province name
+                          L     - Locality name
+                          O     - Organization name
+                          OU    - Organizational unit name
+                          CN    - Common name
+                          emailAddress - E-mail address
+    Returns:   The certificate request in an X509Req object
+    """
+    req = crypto.X509Req()
+    subj = req.get_subject()
+
+    for (key,value) in name.items():
+        setattr(subj, key, value)
+
+    req.set_pubkey(pkey)
+    req.sign(pkey, digest)
+    return req
+
+def createCertificate(req, (issuerCert, issuerKey), serial, (notBefore, notAfter), digest="md5"):
+    """
+    Generate a certificate given a certificate request.
+
+    Arguments: req        - Certificate reqeust to use
+               issuerCert - The certificate of the issuer
+               issuerKey  - The private key of the issuer
+               serial     - Serial number for the certificate
+               notBefore  - Timestamp (relative to now) when the certificate
+                            starts being valid
+               notAfter   - Timestamp (relative to now) when the certificate
+                            stops being valid
+               digest     - Digest method to use for signing, default is md5
+    Returns:   The signed certificate in an X509 object
+    """
+    cert = crypto.X509()
+    cert.set_serial_number(serial)
+    cert.gmtime_adj_notBefore(notBefore)
+    cert.gmtime_adj_notAfter(notAfter)
+    cert.set_issuer(issuerCert.get_subject())
+    cert.set_subject(req.get_subject())
+    cert.set_pubkey(req.get_pubkey())
+    cert.sign(issuerKey, digest)
+    return cert
+
diff --git a/chem_db.py b/chem_db.py
new file mode 100644 (file)
index 0000000..181d066
--- /dev/null
@@ -0,0 +1,438 @@
+#!/usr/bin/python
+
+"""
+Extend text_db with a CAS# validator, a command line interface, and document generation.
+"""
+
+from text_db import *
+import re
+import os
+import os.path
+import time
+
+def valid_CASno(cas_string, debug=False):
+    """
+    Check N..NN-NN-N format, and the checksum digit for valid CAS number structure.
+    see http://www.cas.org/expertise/cascontent/registry/checkdig.html
+    for N_n .. N_4 N_3 - N_2 N_1 - R
+    R = remainder([sum_{i=1}^n i N_i  ] / 10 )
+    Ignores 'na' and '+secret-non-hazardous'
+    >>> valid_CASno('107-07-3')
+    True
+    >>> valid_CASno('107-08-3')
+    False
+    >>> valid_CASno('107-083')
+    False
+    """
+    for string in ['na', '+secret-non-hazardous']:
+        # the first marks a non-existent CAS#
+        # the last marks items with secret, non-hazardous ingredients for which we have no CAS#
+        if cas_string == string:
+            return True
+    # check format,  
+    # ^ matches the start of the string
+    # \Z matches the end of the string
+    regexp = re.compile('^[0-9]{2,}[-][0-9]{2}[-][0-9]\Z')
+    if regexp.match(cas_string) == None:
+        if debug : print >> stderr, "invalid CAS# format: '%s'" % cas_string
+        return False
+    # generate check digit
+    casdigs = "".join(cas_string.split('-')) # remove '-'
+    sumdigs = list(casdigs[:-1])
+    sumdigs.reverse()
+    sum=0
+    for i in range(len(sumdigs)) :
+        sum += (i+1)*int(sumdigs[i])
+    check = sum % 10
+    if int(casdigs[-1]) == check :
+        return True
+    else :
+        if debug : print >> stderr, "invalid CAS# check: '%s' (expected %d)" % (cas_string, check)
+        return False
+
+class MSDS_manager (object) :
+    """
+    Manage Material Saftey Data Sheets (MSDSs)
+    """
+    def __init__(self, db, dir="./MSDS/") :
+        self.db = db
+        self.dir = dir
+        self.MIMEs = ['application/pdf',
+                      'text/html',
+                      'text/plain']
+        self.MIME_exts = ['pdf', 'html', 'txt']
+        self.check_dir()
+    def check_dir(self) :
+        "Create the MSDS directory if it's missing"
+        if os.path.isdir(self.dir) :
+            return # all set to go
+        elif os.path.exists(self.dir) :
+            raise Exception, "Error: a non-directory file exists at %s" % self.dir
+        else :
+            os.mkdir(self.dir)
+    def basepath(self, id) :
+        assert type(id) == type(1), 'id must be an integer, not %s (%s)' \
+                                    % (type(id), str(id))
+        return os.path.join(self.dir, "%d" % id)
+    def local_basepath(self, id) : # for symbolic links
+        assert type(id) == type(1), 'id must be an integer, not %s (%s)' \
+                                    % (type(id), str(id))
+        return "./%d" % id
+    def MIME_ext(self, mime) :
+        assert mime in self.MIMEs, \
+            "invalid MIME type '%s'\nshould be one of %s" % (mime, self.MIMEs)
+        i = self.MIMEs.index(mime)
+        ext = self.MIME_exts[i]
+        return ext
+    def path(self, id, mime) :
+        return "%s.%s" % (self.basepath(id), self.MIME_ext(mime))
+    def local_path(self, id, mime) :
+        return "%s.%s" % (self.local_basepath(id), self.MIME_ext(mime))
+    def save(self, id, filetext, mime='application/pdf') :
+        "Save the binary byte string FILE to the path for ID"
+        print >> file(self.path(id, mime), 'wb'), filetext,
+    def link(self, id, target_id) :
+        # target_id already exists, create a symlink to it for id.
+        target_mime = self.get_MSDS_MIME(target_id)
+        target_path = self.local_path(target_id, target_mime)
+        path = self.path(id, target_mime)
+        #os.link(self.path(target_id), self.path(id))   # hard link...
+        os.symlink(target_path, path)                   # ... or soft link
+    def has_MSDS_MIME(self, id, mime) :
+        """
+        >>> m = MSDS_manager(db=None)
+        >>> print m.has_MSDS_type(102, 'pdf') # test on html
+        False
+        >>> print m.has_MSDS_type(102, 'html') # test on html
+        True
+        >>> print m.has_MSDS_type(6, 'pdf') # test on pdf symlink
+        True
+        """
+        return os.path.exists(self.path(id, mime))
+    def get_MSDS_path(self, id) :
+        """
+        >>> m = MSDS_manager(db=None)
+        >>> print m.get_MSDS_path(102) # test on html
+        ./MSDS/102.html
+        >>> print m.get_MSDS_path(1) # test on pdf
+        ./MSDS/1.pdf
+        >>> print m.get_MSDS_path(6) # test on pdf symlink
+        ./MSDS/6.pdf
+        """
+        for mime in self.MIMEs :
+            if self.has_MSDS_MIME(id, mime) :
+                return self.path(id, mime)
+        return None
+    def get_MSDS_MIME(self, id) :
+        """
+        >>> m = MSDS_manager(db=None)
+        >>> print m.get_MSDS_MIME(102) # test on html
+        text/html
+        >>> print m.get_MSDS_MIME(1) # test on pdf
+        application/pdf
+        >>> print m.get_MSDS_MIME(6) # test on pdf symlink
+        application/pdf
+        """
+        for mime in self.MIMEs :
+            if self.has_MSDS_MIME(id, mime) :
+                return mime
+        return None
+    def has_MSDS(self, id) :
+        if self.get_MSDS_path(id) == None :
+            return False
+        return True
+    def get_all(self, simlinks=True) :
+        ret = []
+        for record in self.db.records() :
+            p = self.get_MSDS_path( int(record['ID']) )
+            if p != None :
+                if simlinks == False and os.path.islink( p ) :
+                    continue # ignore the symbolic link
+                ret.append({'ID':record['ID'], 'Name':record['Name']})
+        return ret
+
+class docgen (object) :
+    "Generate the officially required documents"
+    def __init__(self, db) :
+        self.db = db
+    def _latex_safe(self, string):
+        string = string.replace('%', '\%')
+        string = string.replace('>', '$>$')
+        string = string.replace('<', '$<$')
+        return string
+    def _set_main_target(self, target):
+        print >> file('./docs/main.tex', 'w'), \
+            """\documentclass[letterpaper]{article}
+
+\input{%s}
+""" % target
+    def _make_pdf(self, target_file):
+        os.system('cd ./docs && make pdf')
+        path = os.path.join('./docs/', target_file)
+        os.system('cp ./docs/main.pdf %s' % path)
+        return path
+    def inventory(self, namewidth='a') :
+        pp = db_pretty_printer(self.db)
+        active_ids = []
+        for record in self.db.records() :
+            if record['Disposed'] == '' : # get ids for chemicals we still have
+                active_ids.append(record['db_id'])
+        active_fields = ['ID', 'Name', 'Amount',
+                         'H', 'F', 'R', 'O', 'M', 'C', 'T']
+        width = {}
+        for field in active_fields :
+            width[field] = 'a'
+        width['Name'] = namewidth
+        ## Plain text method
+        #string = "Chemical inventory:\t\tGenerated on %s\n\n" \
+        #         % time.strftime('%Y-%m-%d')
+        #string += pp.multi_record_string(active_ids, active_fields,
+        #                                 width=width, FS=' ')
+        # return string
+        ## Latex method
+        string = "\\begin{longtable}{l l l c c c c c c c}\n"
+        string += ('%% The header for the remaining page(s) of the table...\n'
+                   'ID & Name & Amount & H & F & R & O & M & C & T \\\\\n'
+                   '\\hline\n'
+                   '\\endhead\n')
+        for db_id in active_ids :
+            record = self.db.record(db_id)
+            string += "  %s & %s & %s & %s & %s & %s & %s & %s & %s & %s \\\\\n" \
+                      % (self._latex_safe(record['ID']),
+                         self._latex_safe(record['Name']),
+                         self._latex_safe(record['Amount']),
+                         self._latex_safe(record['H']),
+                         self._latex_safe(record['F']),
+                         self._latex_safe(record['R']),
+                         self._latex_safe(record['O']),
+                         self._latex_safe(record['M']),
+                         self._latex_safe(record['C']),
+                         self._latex_safe(record['T']))
+        string += "\\end{longtable}\n"
+        print >> file('./docs/inventory_data.tex', 'w'), string
+        ## alter main.tex to point to the inventory template.
+        self._set_main_target('inventory_template')
+        ## run latex
+        path = self._make_pdf('inventory.pdf')
+        return path
+    def door_warning(self) :
+        pp = db_pretty_printer(self.db)
+        all_ids = range(self.db.len_records())
+
+        # Search the database to find the nasties
+        NFPA_maxs = {'H':0, 'F':0, 'R':0, 'O':[]}
+        Mutagens = []
+        Carcinogens = []
+        Teratogens = []
+        Healths = []
+        Fires = []
+        Reactivities = []
+        Others = []
+        for record in self.db.records() :
+            if record['Disposed'] == '' : # chemicals we still have
+                for field in ['H', 'F', 'R', 'O'] :
+                    r = record[field]
+                    if r != '' and r != '?' :
+                        if field != 'O' and int(r) > NFPA_maxs[field] :
+                            NFPA_maxs[field] = int(r)
+                        elif field == 'O' and not r in NFPA_maxs['O'] :
+                            NFPA_maxs[field].append(r)
+                for field,array in zip(['M','C','T'],
+                                       [Mutagens,
+                                        Carcinogens,
+                                        Teratogens]) :
+                    if record[field] != '' and record[field] != '?':
+                        array.append(record['db_id'])
+        # now that we've found the max NFPAs,
+        # find all the chemicals at those levels
+        for record in self.db.records() :
+            if record['Disposed'] == '' : # chemicals we still have
+                for field,array in zip(['H', 'F', 'R', 'O'],
+                                       [Healths, Fires,
+                                        Reactivities, Others]) :
+                    r = record[field]
+                    if r != '' and r != '?' :
+                        if field != 'O' and int(r) == NFPA_maxs[field] :
+                            array.append(record['db_id'])
+                        elif field == 'O' and r in NFPA_maxs['O'] :
+                            array.append(record['db_id'])
+
+        ## generate the output
+        # first, update the NFPA grapic code
+        if 'OX' in NFPA_maxs['O'] : OX = 'y'
+        else :                      OX = 'n'
+        if 'W'  in NFPA_maxs['O'] : W  = 'y'
+        else :                      W  = 'n'
+        os.system('./docs/mp/gen_NFPA.sh %d %d %d %s %s > ./docs/mp/NFPA.mp'
+                  % (NFPA_maxs['H'], NFPA_maxs['F'], NFPA_maxs['R'], OX, W))
+        # now generate a list of the nasties ( Amount & ID & Name )
+        string = "\\begin{tabular}{r r l}\n"
+        for field,name,array in zip(['H', 'F', 'R', 'O'],
+                                    ['Health', 'Fire',
+                                     'Reactivity', 'Other'],
+                                    [Healths, Fires,
+                                     Reactivities, Others]) :
+            string += "  \multicolumn{3}{c}{\Tstrut %s : %s} \\\\\n" \
+                      % (name, NFPA_maxs[field])
+            for db_id in array :
+                record = self.db.record(db_id)
+                string += "  %s  &  %s &  %s \\\\\n" \
+                    % (self._latex_safe(record['Amount']),
+                       self._latex_safe(record['ID']),
+                       self._latex_safe(record['Name']))
+            if len(array) == 0 :
+                string += "  \multicolumn{3}{c}{ --- } \\\\\n"
+        for hazard,array in zip(['Mutagens','Carcinogens','Teratogens'],
+                                [Mutagens, Carcinogens, Teratogens]) :
+            string += "  \multicolumn{3}{c}{\Tstrut %s} \\\\\n" % (hazard)
+            for db_id in array :
+                record = self.db.record(db_id)
+                string += "  %s  &  %s &  %s \\\\\n" \
+                    % (self._latex_safe(record['Amount']),
+                       self._latex_safe(record['ID']),
+                       self._latex_safe(record['Name']))
+            if len(array) == 0 :
+                string += "  \multicolumn{3}{c}{ --- } \\\\\n"                
+        string += "\\end{tabular}\n"
+        print >> file('./docs/door_data.tex', 'w'), string
+        ## alter main.tex to point to the door template.
+        self._set_main_target('door_template')
+        ## run latex
+        path = self._make_pdf('door_warning.pdf')
+        return path
+
+def _test():
+    import doctest
+    doctest.testmod()
+
+def open_IOfiles(ifilename=None, ofilename=None, debug=False):
+    if ifilename :
+        if debug :  print >> stderr, "open input file '%s'" % ifilename
+        ifile = file(ifilename, 'r')
+    else :
+        ifile = stdin
+    if ofilename :
+        if debug :  print >> stderr, "open output file '%s'" % ofilename
+        ofile = file(ofilename, 'w')
+    else :
+        ofile = stdout    
+    return (ifile, ofile)
+
+def close_IOfiles(ifilename=None, ifile=stdin, 
+                  ofilename=None, ofile=stdout,
+                  debug=False):
+    if ifilename :
+        if debug :  print >> stderr, "close input file '%s'" % ifilename
+        ifile.close()
+    if ofilename :
+        if debug :  print >> stderr, "close output file '%s'" % ofilename
+        ofile.close()
+
+
+if __name__ == "__main__" :
+    from optparse import OptionParser
+
+    parser = OptionParser(usage="usage: %prog [options]", version="%prog 0.1")
+
+    parser.add_option('-f', '--input-file', dest="ifilename",
+                      help="Read input from FILE (default stdin)",
+                      type='string', metavar="FILE")
+    parser.add_option('-o', '--output-file', dest="ofilename",
+                      help="Write output to FILE (default stdout)",
+                      type='string', metavar="FILE")
+    parser.add_option('-d', '--delimiter', dest="FS", # field seperator
+                      help="Set field delimiter (default '%default')",
+                      type='string', metavar="DELIM", default='\t')
+    parser.add_option('-p', '--print-fields', dest="print_fields",
+                      help="Only print certain fields (e.g. 0,3,4,2)",
+                      type='string', metavar="FIELDS")
+    parser.add_option('-r', '--print-records', dest="print_records",
+                      help="Only print certain records (e.g. 0:3)",
+                      type='string', metavar="RECORDS")
+    parser.add_option('-w', '--column-width', dest="width",
+                      help="Set column width for short-format output.",
+                      type='string', metavar="WIDTH")
+    parser.add_option('-L', '--long-format', dest="long_format",
+                      help="Print long format (several lines per record)",
+                      action='store_true', default=False)
+    parser.add_option('-l', '--short-format', dest="long_format",
+                      help="Print short format (default) (one lines per record)",
+                      action='store_false', default=False)
+    parser.add_option('-t', '--test', dest="test",
+                      help="Run docutils tests on db.py",
+                      action="store_true", default=False)
+    parser.add_option('-V', '--validate', dest="validate",
+                      help="Validate CAS#s (no other output)",
+                      action="store_true", default=False)
+    parser.add_option('-v', '--verbose', dest="verbose",
+                      help="Print lots of debugging information",
+                      action="store_true", default=False)
+
+    (options, args) = parser.parse_args()
+    parser.destroy()
+
+    ifile,ofile = open_IOfiles(options.ifilename, options.ofilename,
+                               options.verbose)
+
+    if options.test :
+        _test()
+    elif options.validate :
+        db = text_db(filename=None)
+        pp = db_pretty_printer(db)
+
+        # read in and parse the file
+        db._parse(ifile.read())
+
+        CAS_DELIM = ',' # seperate CAS entries for chemicals with multiple CAS numbers
+        PERCENT_DELIM = ':' # seperate CAS number from ingredient percentage
+        for record in db.records() :
+            valid = True
+            cas = record['CAS#']
+            if len(cas.split(CAS_DELIM)) == 0 : # cas = 'N...N-NN-N'
+                if not valid_CASno(cas, options.verbose) :
+                    valid = False
+                    print >> ofile, "Invalid CAS# in record: '%s'" % cas
+            else : # cas = 'N...N-NN-N:X%,N...N-NN-N:Y%,...'
+                for casterm in cas.split(CAS_DELIM) : # casterm = 'N...N-NN-N:X%'
+                    c = casterm.split(PERCENT_DELIM)[0]   # c = 'N...N-NN-N'
+                    if not valid_CASno(c, options.verbose) :
+                        valid = False
+                        print >> ofile, "Invalid CAS* in record: '%s'" % c
+            if not valid :
+                print >> ofile, "in record %s: %s" % (record['ID'], record['Name'])
+                #pp.full_record_string(record)
+
+    else :
+        db = text_db(filename=None)
+
+        # read in and parse the file
+        db._parse(ifile.read())
+        pp = db_pretty_printer(db)
+        if options.long_format :
+            for id in pp._norm_record_ids(options.print_records) :
+                string = pp.full_record_string_id(id)
+        else :
+            # pythonize the width option
+            if (options.width != None
+                and options.width != 'a'
+                and len(options.width.split(':')) == 1
+                ) :
+                width = int(options.width)
+            elif len(options.width.split(':')) > 1 :
+                width = {}
+                for kv in options.width.split(',') :
+                    spl = kv.split(':')
+                    assert len(spl) == 2, 'invalid width "%s" in "%s"' % (kv, options.width)
+                    if spl[1] == 'a' :
+                        width[spl[0]] = spl[1]
+                    else :
+                        width[spl[0]] = int(spl[1])
+
+            string = pp.multi_record_string(options.print_records,
+                                            options.print_fields,
+                                            width,
+                                            options.FS)
+            print >> ofile, string,
+            
+    close_IOfiles(options.ifilename, ifile,
+                  options.ofilename, ofile, options.verbose)
diff --git a/chem_web.py b/chem_web.py
new file mode 100755 (executable)
index 0000000..ea362c5
--- /dev/null
@@ -0,0 +1,560 @@
+#!/usr/bin/python
+#
+# Web application for posting additions/modifications to the chem-inventory
+# derived from simplewiki and wiki_py, by Adam Bachman
+#  ( http://bachman.infogami.com/ )
+# Authentication following Snakelets
+# http://snakelets.sourceforge.net/manual/authorization.html
+
+# Remember to delete the templates directory after making alterations,
+# because this program only generates templates if they are missing.
+
+
+## Bring in some useful goodies:
+# web, because we're running a web server, and generating html;
+# time, for timestamping backups;
+# os, for creating the template directories; and
+# re, for replacing internal node links with proper markup
+import web, os, re
+# md5, for hashing passwords
+#import md5
+
+# database must support the following methods :
+#   field_list() : return an ordered list of available fields (fields unique)
+#   long_field() : return an dict of long field names (keyed by field names)
+#   record(id)   : return a record dict (keyed by field names)
+#   records()    : return an ordered list of available records.
+#   backup()     : save a copy of the current database somehow.
+#   set_record(i, newvals) : set a record by overwriting any preexisting data
+#                          : with data from the field-name-keyed dict NEWVALS
+import text_db
+database = text_db.text_db
+from chem_db import valid_CASno, MSDS_manager, docgen
+DB_FILE = 'inventory.db'
+import sys, daemon, logging
+PID_FILE = './chem_web.pid'
+LOG_FILE = './chem_web.log'
+
+## Also import a markup language, and associated regexp functions
+from markdown import markdown
+## for converting the first character in record names, and stripping |
+from string import upper, rstrip
+
+## for SSL
+SSL = False
+if SSL :
+      from mk_simple_certs import get_cert_filenames
+      server_name = 'chem_web'
+
+__version__ = "0.2"
+
+## For web.py, when a URL matches '/view', handle with class 'view'
+# when it matches '/edit', handle with class 'edit',
+# and when it matches '/login', handle with class 'login',
+# Note, there are no .html extension.
+#
+# link to edit pages using db_id
+# link to MSDSs with ID
+urls = ('/(view)?', 'view',
+        '/edit/([0-9]+)', 'edit',
+        '/MSDS/([0-9]+)', 'MSDS',
+        '/docs/([a-z._]*)', 'docs',
+        '/login', 'login')
+
+# limit the display width of selected fields
+WIDTH_MAX = {'CAS#':12}
+
+# set the display width of entry fields
+ENTRY_WIDTH = 50
+
+class view (object) :
+      "Print the database"
+      # when the server recieves the command "GET '/view', call view.get('view')
+      def GET(self, name):
+            """
+            Render the view.html template using name and the previous text.
+            If the file doesn't exist, use default text '%s doesn't exist'.
+            """
+            if name == None :
+                  name = 'view'
+            db._refresh()
+            recs = []
+            for record in db.records() :
+                  id = int(record['ID'])
+                  record['ID'] = '<a href="/edit/%d">%s</a>' \
+                                 % (id, record['ID'])
+                  if MSDSman.has_MSDS(id) : # link the id to the MSDS
+                        record['Name'] = '<a href="/MSDS/%d">%s</a>' \
+                                 % (record['db_id'], record['Name'])
+                  recs.append(record)
+            print render.view('Chemical inventory',
+                              db.len_records(), db.field_list(), recs)
+
+class edit (object) :
+      "Provide methods for handling requests related to edit pages"
+      def GET(self, name):
+            """
+            Render the edit.html template using the specified record number.
+            """
+            db._refresh()
+            db_id = int(name)
+            if db_id >= db.len_records() :
+                  logging.info("new record")
+                  assert db_id == db.len_records(), \
+                      'invalid id: %d (max %d)' % (db_id, db.len_records())
+                  record = db.new_record()
+                  record['ID'] = str(record['db_id'])
+                  db.set_record(db_id, record)
+            if MSDSman.has_MSDS(db_id) :
+                  MSDSs = None
+            else :
+                  # only ask for an MSDS if we still need one
+                  MSDSs = MSDSman.get_all(simlinks=False)
+            print render.edit(int(name), db.field_list(),
+                              db.long_fields(), db.record(int(name)), MSDSs)
+      def POST(self, name):
+            """
+            Read the form input and update the database accordingly.
+            Then redirect to /view.
+            """
+            db._refresh()
+            record = db.record(int(name))
+            ## Generate a new record from the form input
+            # 'MSDS={}' to storify MSDS as a FieldStorage (dict-like) instance,
+            # otherwise it's saved as a string, and we loose info about it.
+            # e.g. newvals['MSDS'].type = 'text/html'
+            # The contents of the file are saved to newvals['MSDS'].value
+            newvals = web.input(MSDS={})
+            update = False
+            for field in db.field_list() :
+                  if newvals[field] != record[field] :
+                        update=True
+                        record[field] = newvals[field]
+            if 'MSDS source' in newvals :
+                  # Handle any MSDS file actions
+                  if newvals['MSDS source'] == 'upload' :
+                        if len(newvals['MSDS'].filename) > 0 and len(newvals['MSDS'].value) > 0:
+                              # only save if there is a file there to save ;)
+                              #print >> stderr, web.ctx.env['CONTENT_TYPE']
+                              #print >> stderr, web.ctx.keys()
+                              #print >> stderr, dir(newvals['MSDS']) 
+                              #print >> stderr, type(newvals['MSDS'])
+                              #print >> stderr, newvals['MSDS'].filename 
+                              #print >> stderr, newvals['MSDS'].type
+                              MSDSman.save(int(name), newvals['MSDS'].value,
+                                           newvals['MSDS'].type)
+                  else :
+                        logging.info('linking MSDS %d to %d' \
+                                         % (int(record['ID']),
+                                            int(newvals['MSDS share'])) )
+                        MSDSman.link(int(record['ID']),
+                                     int(newvals['MSDS share']))
+            if update :
+                  db.set_record(int(name), record, backup=True)
+            # redirect to view all
+            web.seeother('/view')
+
+class MSDS (object) :
+      "Serve MSDS files by ID"
+      def GET(self, name):
+            "Serve MSDS files by ID"
+            id = int(name)
+            if MSDSman.has_MSDS(id) :
+                  mime = MSDSman.get_MSDS_MIME(id)
+                  web.header("Content-Type", mime)
+                  print file(MSDSman.get_MSDS_path(id), 'rb').read() ,
+            else :
+                  print render.error(id)
+
+class docs (object) :
+      "Generate and serve assorted official documents"
+      def GET(self, name):
+            """
+            List the available documents.
+            """
+            db._refresh()
+            if name == '' :
+                  print render.docs()
+            if name == 'inventory.pdf' :
+                  path = dgen.inventory(namewidth=40)
+                  print file(path, 'rb').read() ,
+            if name == 'door_warning.pdf' :
+                  path = dgen.door_warning()
+                  print file(path, 'rb').read() ,
+                  
+
+class login (object) :
+      "Print an alphabetized index of all existing pages"
+      def GET(self):
+            print "Not yet implemented"
+            #print render.stat('login')
+
+class markup (object) :
+      "Convert text to html, using Markdown with a bit of preformatting"
+      def __init__(self) :
+            # [[optional display text|name with or_without_spaces]]
+            linkregexp = ('\[\['
+                          +'([^\|\[\]]*\|)?'
+                          +'([a-zA-Z0-9-_ ]*)'
+                          +'\]\]')
+            #print linkregexp
+            self.linkexp = re.compile(linkregexp)
+            self.linebreak = '  \n'
+            self.uscore = re.compile('_')
+            self.space = re.compile(' ')
+      def htmlize(self, text) :
+            return text
+            pre_markup = self._preformat(text)
+            #print "Preformatted '"+pre_markup+"'"
+            return self._markup( pre_markup )
+      def _markup(self, text) :
+            # markdown() implements the text->html Markdown semantics
+            # the str() builtin ensures a nice, printable string
+            return str(markdown(text))
+      def _preformat(self, text) :
+            #print "Preformatting '"+text+"'"
+            return self._autolink(text)
+      def _autolink(self, text) :
+            "Replace linkexps in text with Markdown syntax versions"
+            # sub passes a match object to link2markup for each match
+            # see http://docs.python.org/lib/match-objects.html
+            return self.linkexp.sub(self.matchlink2markup, text)
+      def matchlink2markup(self, match) :
+            # The name is the first (and only) matched region
+            #print match.groups()
+            name = match.groups()[1]
+            text = match.groups()[0]
+            return self.link2markup(text, name)
+      def linklist(self, text) :
+            linklist = [] # empty list
+            for m in self.linkexp.finditer(text) :
+                  linklist.append( self.str2name(m.groups()[1]) )
+            #print linklist
+            return linklist
+      def link2markup(self, text, name) :
+            """
+            usage: string = link2markup(text, target)
+            takes the string name of a wiki page,
+            and returns a string that will be markuped into a link to that page
+            displaying the text text.
+            """
+            # convert to Markdown pretty link syntax
+            if text : # remove trailing |
+                  text = text.rstrip('|')
+            else :
+                  text = self.name2str(name)
+            name = self.str2name(name)
+            return str('['+text+'](/'+name+')')
+      def name2str(self, name) :
+            """
+            usage: string = name2str(name)
+            takes the string name of a wiki page,
+            and converts it to display format
+            See str2name()
+            """
+            w_spaces = self.uscore.sub(' ', name)
+            return w_spaces
+      def str2name(self, string) :
+            """
+            usage: string = name2str(name)
+            Converts strings to the relevent wiki page name.
+            See str2name()
+            """
+            wo_spaces = self.space.sub('_', string)
+            cap_first = upper(wo_spaces[0]) + wo_spaces[1:]
+            return cap_first
+      def make_add_button(self, next_db_id) :
+            string =  '<form action="/edit/%d" method="get">\n' % next_db_id
+            string += ' <input type="submit" value="Add entry">\n'
+            string += '</form><br>\n'
+            return string
+      def make_table(self, fields, records, width_max=WIDTH_MAX, 
+                     raw_fields=()):
+            """
+            >>> print make_table(['A','B','C'],[{'A':'a','B':'b'},{'A':'d','B':'e','C':'f'}]),
+            <table>
+            <tr><td><b>A</b></td><td><b>B</b></td><td><b>C</b></td></tr>
+            <tr><td>a</td><td>b</td><td></td></tr>
+            <tr><td>d</td><td>e</td><td>f</td></tr>
+            </table>
+            """
+            # open the table
+            string = '<table>\n'
+            # add the header
+            string += '<tr>'
+            for field in fields :
+                  string += '<td><b>%s<b></td>' % self.htmlize(field)
+            string += '</tr>\n'
+            # add the records
+            for record in records :
+                  string += '<tr>'
+                  for field in fields :
+                        rstring = record[field]
+                        # truncate if the width is regulated...
+                        if field in width_max :
+                              w = width_max[field]
+                              # ... and the full string is too long
+                              if len(rstring) > w :
+                                    rstring = "%s..." % rstring[:w]
+                        # if the field isn't raw, protect special chars
+                        if not field in raw_fields :
+                              rstring = self.htmlize(rstring)
+                        string += '<td>%s</td>' % rstring
+                  string += '</tr>\n'
+            # close the table
+            string += '</table>\n'
+            return string
+      def record_form_entries(self, index, fields, lfields, record, MSDSs=None, entry_width=ENTRY_WIDTH):
+            """
+            >>> print record_form_entries(4,['A','B','MSDS'],{'A':'AA','B':'BB','MSDS'},{'A':'a','B':'b'}),
+            <table>
+             <tr><td><label for="A">AA</label></td><td>:</td>
+              <td><input type="text" size="50" id="A" name="A" value="a"></td></tr>
+             <tr><td><label for="B">BB</label></td><td>:</td>
+              <td><input type="text" size="50" id="B" name="B" value="b"></td></tr>
+             <tr><td><label for="MSDS">MSDS</label></td><td>:</td>
+              <td><input type="file" size="50" id="MSDS" name="MSDS" value=""></td></tr>
+            </table>
+            """
+            # open the table
+            string = '<table>\n'
+            # add the record fields
+            for field in fields :
+                  # get the record string
+                  rstring = self.htmlize(record[field])
+                  fstring = self.htmlize(field)
+                  lfstring = self.htmlize(lfields[field])                  
+                  string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' % (fstring, lfstring)
+                  string += '  <td><input type="text" size="%d" id="%s" name="%s" value="%s"></td></tr>\n' \
+                            % (entry_width, fstring, fstring, rstring)
+            if MSDSs != None :
+                  ## add an MSDS radio, share menu, and file upload fields
+                  # radio
+                  lfstring = fstring = 'MSDS source'
+                  string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
+                            % (fstring, lfstring)
+                  string += '  <td><input type="radio" id="%s" name="%s" value="upload" checked>Upload\n' \
+                            % (fstring, fstring)
+                  string += '      <input type="radio" id="%s" name="%s" value="share">Share</td></tr>\n' \
+                            % (fstring, fstring)
+
+                  # file upload
+                  fstring = 'MSDS'
+                  lfstring = 'Upload MSDS'
+                  string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
+                            % (fstring, lfstring)
+                  string += '  <td><input type="file" size="%d" id="%s" name="%s" accept="text/plain,text/html,application/pdf" value=""></td></tr>\n' \
+                            % (entry_width, fstring, fstring)
+
+                  # file share menu
+                  fstring = 'MSDS share'
+                  lfstring = 'Use an already uploaded MSDS'
+                  string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
+                            % (fstring, lfstring)
+                  string += '  <td><select size="1" id="%s" name="%s">\n' \
+                            % (fstring, fstring)
+                  for MSDS in MSDSs :
+                        lfstring = self.htmlize(MSDS['Name'])
+                        id = int(MSDS['ID'])
+                        string += '   <option value="%d">%d: %s</option>\n' \
+                                  % (id, id, lfstring)
+                  string += '   </select></td></tr>\n'
+            # close the table
+            string += '</table><br>\n'
+            return string
+
+class templt (object) :
+     "Handle some template wrapping"
+     def __init__(self) :
+           self.dir = 'templates/'
+           if not os.path.exists(self.dir): os.mkdir(self.dir)
+           ## Write simple template html pages if they don't already exist.
+           # see ( http://webpy.org/templetor ) for the syntax.
+           html=('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"\n'
+                 ' "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">\n'
+                 '<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">\n')
+           if not os.path.exists(self.dir+'view.html') :
+                 view=('$def with (name, next_db_id, fields, records)\n'
+                       +html+
+                       ' <head>\n'
+                       '  <title>\n'
+                       '   $:name2str(name) \n'
+                       '  </title>\n'
+                       ' </head>\n'
+                       ' <body>\n'
+                       '  <h1>\n'
+                       '   $:name2str(name)\n'
+                       '  </h1>\n'
+                       '  See \n'
+                       '   <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm">the rules</a>\n'
+                       '   for more information.\n'
+                       '  See the <a href="/docs/">docs</a> page to generate required documents.<br>\n'
+                       '  <br>\n'
+                       '  $:make_add_button(next_db_id)\n'
+                       '  $:make_table(fields, records)\n'
+                       '  $:make_add_button(next_db_id)\n'
+                       ' </body>\n'
+                       '</html>\n')
+                 file(self.dir+'view.html', 'w').write(view)
+           if not os.path.exists(self.dir+'edit.html') :
+                 # note:
+                 # the form encoding type 'enctype="multipart/form-data">\n'
+                 # is only required because of the MSDS file-upload field.
+                 edit=('$def with (index, fields, lfields, record, MSDSs)\n'
+                       +html+
+                       ' <head>\n'
+                       '  <title>\n'
+                       '   $:htmlize(record["Name"]) \n'
+                       '  </title>\n'
+                       ' </head>\n'
+                       ' <body>\n'
+                       '  <a style="cursor:pointer;" onclick="history.back()"><h1>\n'
+                       '   Editing: $:htmlize(record["Name"]) \n'
+                       '  </h1></a>\n'
+                       '  <form action="" method="post" enctype="multipart/form-data">\n'
+                       '   $:record_form(index, fields, lfields, record, MSDSs)<br />\n'
+                       '   <input type="submit" value="Submit" />\n'
+                       '  </form>\n'
+                       ' </body>\n'
+                       '</html>\n')
+                 file(self.dir+'edit.html', 'w').write(edit)
+           if not os.path.exists(self.dir+'error.html') :
+                 # like view, but no edit ability
+                 #  since the content is generated,
+                 # or backlink list, since there would be so many.
+                 stat=('$def with (id)\n'
+                       +html+
+                       ' <head>\n'
+                       ' </head>\n'
+                       ' <body>\n'
+                       '  <h1>Error</h1>\n'
+                       '  There is currently no MSDS file for \n'
+                       '  $:htmlize(id)\n'
+                       ' </body>\n'
+                       '</html>\n')
+                 file(self.dir+'error.html', 'w').write(stat)
+           if not os.path.exists(self.dir+'docs.html') :
+                 docs=(html +
+                       '<a href="/docs/inventory.pdf">Inventory</a>\n'
+                       ' in accordance with the \n'
+                       ' <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">\n'
+                       '  Chemical Hygiene Plan Section E-7</a>.<br>\n'
+                       '<a href="/docs/door_warning.pdf">Door warning</a>\n'
+                       ' in accordance with the Chemical Hygiene Plan Sections\n'
+                       ' <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">E-7</a>\n'
+                       ' and \n'
+                       ' <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe10">E-10</a>\n'
+                       '.<br>\n'
+                       ' </body>\n'
+                       '</html>\n')
+                 file(self.dir+'docs.html', 'w').write(docs)
+
+
+class chem_web_daemon (daemon.Daemon) :
+      gid = None
+      uid = None
+      pidfile = PID_FILE
+      logfile = LOG_FILE
+      loglevel = 'INFO'
+      def run(self) :
+            if self.pkey_file == None or self.cert_file == None :
+                  logging.info("http://%s:%d/" % web.validip(self.options.ip))
+            else :
+                  logging.info("https://%s:%d/" % web.validip(self.options.ip))
+
+            ## web.py should give detailed error messages, not just `internal error'
+            web.internalerror = web.debugerror
+
+            ## How we'd start webpy server if we didn't need command line args or SSL
+            # Pass web my URL regexps, and the globals from this script
+            # You can also pass it web.profiler to help optimize for speed
+            #web.run(urls, globals())
+            ## How we have to start it now
+            webpy_func = web.webpyfunc(urls, globals(), False)
+            wsgi_func = web.wsgifunc(webpy_func)
+            web.httpserver.runsecure(wsgi_func,
+                                     web.validip(self.options.ip),
+                                     ssl_private_key_filename=self.pkey_file,
+                                     ssl_certificate_filename=self.cert_file)
+
+      def read_basic_config(self) :
+            pass
+      def parse_options(self):
+            from optparse import OptionParser
+            
+            usage_string = ('%prog [options]\n'
+                            '\n'
+                            '2008, W. Trevor King.\n')
+            version_string = '%%prog %s' % __version__
+            parser = OptionParser(usage=usage_string, version=version_string)
+            
+            # Daemon options
+            parser.add_option('--start', dest='action',
+                              action='store_const', const='start', default='start',
+                              help='Start the daemon (the default action)')
+            parser.add_option('--stop', dest='action',
+                              action='store_const', const='stop', default='start',
+                              help='Stop the daemon')
+            parser.add_option('-n', '--nodaemon', dest='daemonize',
+                              action='store_false', default=True,
+                              help='Run in the foreground')
+            
+            # Server options
+            parser.add_option('-a', '--ip-address', dest="ip",
+                              help="IP address (default '%default')",
+                              type='string', metavar="IP", default='0.0.0.0:8080')
+            parser.add_option('-s', '--secure', dest="secure",
+                              help="Run in secure (HTTPS) mode.",
+                              type='string', metavar="PKEY_FILE:CERT_FILE")
+            parser.add_option('-v', '--verbose', dest="verbose", action="store_true",
+                              help="Print lots of debugging information",
+                              default=False)
+            
+            self.options, self.args = parser.parse_args()
+            parser.destroy()
+            
+            if self.options.verbose :
+                  self.loglevel = 'DEBUG'
+            # get options for httpserver
+            if self.options.secure != None :
+                  split = self.options.secure.split(':')
+                  assert len(split) == 2, "Invalid secure argument '%s'"
+                  self.pkey_file = split[0]
+                  self.cert_file = split[1]
+            else :
+                  self.pkey_file = None
+                  self.cert_file = None
+
+### the following instances and definitions must have
+## global scope. because they are called from inside web
+# create the database
+db = database(filename=DB_FILE)
+MSDSman = MSDS_manager(db)
+dgen = docgen(db)
+
+## set up the templates
+tmplt = templt()
+# Tell templator where to look for templates
+# to provide a framework for the generated webpages
+render = web.template.render(tmplt.dir)
+
+## Define markup functions
+mkup = markup()
+htmlize = lambda t : mkup.htmlize(t)
+name2str = lambda n : mkup.name2str(n)
+mktable = lambda h,r : mkup.make_table(h,r,WIDTH_MAX,raw_fields=('ID','Name'))
+mkadd = lambda ni : mkup.make_add_button(ni)
+record_form = lambda i,f,l,r,M : mkup.record_form_entries(i,f,l,r,M)
+## Give templates access to the htmlize, make_table, and name2str functions
+# ( see http://webpy.org/templetor and our view.html )
+web.template.Template.globals['htmlize'] = htmlize
+web.template.Template.globals['make_table'] = mktable
+web.template.Template.globals['make_add_button'] = mkadd
+web.template.Template.globals['name2str'] = name2str
+web.template.Template.globals['record_form'] = record_form
+
+
+## If this script is run from the command line,
+# use the tools we've just defined to host the chemical inventory
+if __name__=="__main__":
+      chem_web_daemon().main()
diff --git a/chem_web_secure.py b/chem_web_secure.py
new file mode 100755 (executable)
index 0000000..d17395d
--- /dev/null
@@ -0,0 +1,560 @@
+#!/usr/bin/python
+#
+# Web application for posting additions/modifications to the chem-inventory
+# derived from simplewiki and wiki_py, by Adam Bachman
+#  ( http://bachman.infogami.com/ )
+# Authentication following Snakelets
+# http://snakelets.sourceforge.net/manual/authorization.html
+
+# Remember to delete the templates directory after making alterations,
+# because this program only generates templates if they are missing.
+
+
+## Bring in some useful goodies:
+# web, because we're running a web server, and generating html;
+# time, for timestamping backups;
+# os, for creating the template directories; and
+# re, for replacing internal node links with proper markup
+import web, os, re
+# md5, for hashing passwords
+#import md5
+
+# database must support the following methods :
+#   field_list() : return an ordered list of available fields (fields unique)
+#   long_field() : return an dict of long field names (keyed by field names)
+#   record(id)   : return a record dict (keyed by field names)
+#   records()    : return an ordered list of available records.
+#   backup()     : save a copy of the current database somehow.
+#   set_record(i, newvals) : set a record by overwriting any preexisting data
+#                          : with data from the field-name-keyed dict NEWVALS
+import text_db
+database = text_db.text_db
+from chem_db import valid_CASno, MSDS_manager, docgen
+DB_FILE = 'inventory.db'
+import sys, daemon, logging
+PID_FILE = './chem_web_secure.pid'
+LOG_FILE = './chem_web_secure.log'
+
+## Also import a markup language, and associated regexp functions
+from markdown import markdown
+## for converting the first character in record names, and stripping |
+from string import upper, rstrip
+
+## for SSL
+SSL = True
+if SSL :
+      from mk_simple_certs import get_cert_filenames
+      server_name = 'chem_web'
+
+__version__ = "0.2"
+
+## For web.py, when a URL matches '/view', handle with class 'view'
+# when it matches '/edit', handle with class 'edit',
+# and when it matches '/login', handle with class 'login',
+# Note, there are no .html extension.
+#
+# link to edit pages using db_id
+# link to MSDSs with ID
+urls = ('/(view)?', 'view',
+        '/edit/([0-9]+)', 'edit',
+        '/MSDS/([0-9]+)', 'MSDS',
+        '/docs/([a-z._]*)', 'docs',
+        '/login', 'login')
+
+# limit the display width of selected fields
+WIDTH_MAX = {'CAS#':12}
+
+# set the display width of entry fields
+ENTRY_WIDTH = 50
+
+class view (object) :
+      "Print the database"
+      # when the server recieves the command "GET '/view', call view.get('view')
+      def GET(self, name):
+            """
+            Render the view.html template using name and the previous text.
+            If the file doesn't exist, use default text '%s doesn't exist'.
+            """
+            if name == None :
+                  name = 'view'
+            db._refresh()
+            recs = []
+            for record in db.records() :
+                  id = int(record['ID'])
+                  record['ID'] = '<a href="/edit/%d">%s</a>' \
+                                 % (id, record['ID'])
+                  if MSDSman.has_MSDS(id) : # link the id to the MSDS
+                        record['Name'] = '<a href="/MSDS/%d">%s</a>' \
+                                 % (record['db_id'], record['Name'])
+                  recs.append(record)
+            print render.view('Chemical inventory',
+                              db.len_records(), db.field_list(), recs)
+
+class edit (object) :
+      "Provide methods for handling requests related to edit pages"
+      def GET(self, name):
+            """
+            Render the edit.html template using the specified record number.
+            """
+            db._refresh()
+            db_id = int(name)
+            if db_id >= db.len_records() :
+                  logging.info("new record")
+                  assert db_id == db.len_records(), \
+                      'invalid id: %d (max %d)' % (db_id, db.len_records())
+                  record = db.new_record()
+                  record['ID'] = str(record['db_id'])
+                  db.set_record(db_id, record)
+            if MSDSman.has_MSDS(db_id) :
+                  MSDSs = None
+            else :
+                  # only ask for an MSDS if we still need one
+                  MSDSs = MSDSman.get_all(simlinks=False)
+            print render.edit(int(name), db.field_list(),
+                              db.long_fields(), db.record(int(name)), MSDSs)
+      def POST(self, name):
+            """
+            Read the form input and update the database accordingly.
+            Then redirect to /view.
+            """
+            db._refresh()
+            record = db.record(int(name))
+            ## Generate a new record from the form input
+            # 'MSDS={}' to storify MSDS as a FieldStorage (dict-like) instance,
+            # otherwise it's saved as a string, and we loose info about it.
+            # e.g. newvals['MSDS'].type = 'text/html'
+            # The contents of the file are saved to newvals['MSDS'].value
+            newvals = web.input(MSDS={})
+            update = False
+            for field in db.field_list() :
+                  if newvals[field] != record[field] :
+                        update=True
+                        record[field] = newvals[field]
+            if 'MSDS source' in newvals :
+                  # Handle any MSDS file actions
+                  if newvals['MSDS source'] == 'upload' :
+                        if len(newvals['MSDS'].filename) > 0 and len(newvals['MSDS'].value) > 0:
+                              # only save if there is a file there to save ;)
+                              #print >> stderr, web.ctx.env['CONTENT_TYPE']
+                              #print >> stderr, web.ctx.keys()
+                              #print >> stderr, dir(newvals['MSDS']) 
+                              #print >> stderr, type(newvals['MSDS'])
+                              #print >> stderr, newvals['MSDS'].filename 
+                              #print >> stderr, newvals['MSDS'].type
+                              MSDSman.save(int(name), newvals['MSDS'].value,
+                                           newvals['MSDS'].type)
+                  else :
+                        logging.info('linking MSDS %d to %d' \
+                                         % (int(record['ID']),
+                                            int(newvals['MSDS share'])) )
+                        MSDSman.link(int(record['ID']),
+                                     int(newvals['MSDS share']))
+            if update :
+                  db.set_record(int(name), record, backup=True)
+            # redirect to view all
+            web.seeother('/view')
+
+class MSDS (object) :
+      "Serve MSDS files by ID"
+      def GET(self, name):
+            "Serve MSDS files by ID"
+            id = int(name)
+            if MSDSman.has_MSDS(id) :
+                  mime = MSDSman.get_MSDS_MIME(id)
+                  web.header("Content-Type", mime)
+                  print file(MSDSman.get_MSDS_path(id), 'rb').read() ,
+            else :
+                  print render.error(id)
+
+class docs (object) :
+      "Generate and serve assorted official documents"
+      def GET(self, name):
+            """
+            List the available documents.
+            """
+            db._refresh()
+            if name == '' :
+                  print render.docs()
+            if name == 'inventory.pdf' :
+                  path = dgen.inventory(namewidth=40)
+                  print file(path, 'rb').read() ,
+            if name == 'door_warning.pdf' :
+                  path = dgen.door_warning()
+                  print file(path, 'rb').read() ,
+                  
+
+class login (object) :
+      "Print an alphabetized index of all existing pages"
+      def GET(self):
+            print "Not yet implemented"
+            #print render.stat('login')
+
+class markup (object) :
+      "Convert text to html, using Markdown with a bit of preformatting"
+      def __init__(self) :
+            # [[optional display text|name with or_without_spaces]]
+            linkregexp = ('\[\['
+                          +'([^\|\[\]]*\|)?'
+                          +'([a-zA-Z0-9-_ ]*)'
+                          +'\]\]')
+            #print linkregexp
+            self.linkexp = re.compile(linkregexp)
+            self.linebreak = '  \n'
+            self.uscore = re.compile('_')
+            self.space = re.compile(' ')
+      def htmlize(self, text) :
+            return text
+            pre_markup = self._preformat(text)
+            #print "Preformatted '"+pre_markup+"'"
+            return self._markup( pre_markup )
+      def _markup(self, text) :
+            # markdown() implements the text->html Markdown semantics
+            # the str() builtin ensures a nice, printable string
+            return str(markdown(text))
+      def _preformat(self, text) :
+            #print "Preformatting '"+text+"'"
+            return self._autolink(text)
+      def _autolink(self, text) :
+            "Replace linkexps in text with Markdown syntax versions"
+            # sub passes a match object to link2markup for each match
+            # see http://docs.python.org/lib/match-objects.html
+            return self.linkexp.sub(self.matchlink2markup, text)
+      def matchlink2markup(self, match) :
+            # The name is the first (and only) matched region
+            #print match.groups()
+            name = match.groups()[1]
+            text = match.groups()[0]
+            return self.link2markup(text, name)
+      def linklist(self, text) :
+            linklist = [] # empty list
+            for m in self.linkexp.finditer(text) :
+                  linklist.append( self.str2name(m.groups()[1]) )
+            #print linklist
+            return linklist
+      def link2markup(self, text, name) :
+            """
+            usage: string = link2markup(text, target)
+            takes the string name of a wiki page,
+            and returns a string that will be markuped into a link to that page
+            displaying the text text.
+            """
+            # convert to Markdown pretty link syntax
+            if text : # remove trailing |
+                  text = text.rstrip('|')
+            else :
+                  text = self.name2str(name)
+            name = self.str2name(name)
+            return str('['+text+'](/'+name+')')
+      def name2str(self, name) :
+            """
+            usage: string = name2str(name)
+            takes the string name of a wiki page,
+            and converts it to display format
+            See str2name()
+            """
+            w_spaces = self.uscore.sub(' ', name)
+            return w_spaces
+      def str2name(self, string) :
+            """
+            usage: string = name2str(name)
+            Converts strings to the relevent wiki page name.
+            See str2name()
+            """
+            wo_spaces = self.space.sub('_', string)
+            cap_first = upper(wo_spaces[0]) + wo_spaces[1:]
+            return cap_first
+      def make_add_button(self, next_db_id) :
+            string =  '<form action="/edit/%d" method="get">\n' % next_db_id
+            string += ' <input type="submit" value="Add entry">\n'
+            string += '</form><br>\n'
+            return string
+      def make_table(self, fields, records, width_max=WIDTH_MAX, 
+                     raw_fields=()):
+            """
+            >>> print make_table(['A','B','C'],[{'A':'a','B':'b'},{'A':'d','B':'e','C':'f'}]),
+            <table>
+            <tr><td><b>A</b></td><td><b>B</b></td><td><b>C</b></td></tr>
+            <tr><td>a</td><td>b</td><td></td></tr>
+            <tr><td>d</td><td>e</td><td>f</td></tr>
+            </table>
+            """
+            # open the table
+            string = '<table>\n'
+            # add the header
+            string += '<tr>'
+            for field in fields :
+                  string += '<td><b>%s<b></td>' % self.htmlize(field)
+            string += '</tr>\n'
+            # add the records
+            for record in records :
+                  string += '<tr>'
+                  for field in fields :
+                        rstring = record[field]
+                        # truncate if the width is regulated...
+                        if field in width_max :
+                              w = width_max[field]
+                              # ... and the full string is too long
+                              if len(rstring) > w :
+                                    rstring = "%s..." % rstring[:w]
+                        # if the field isn't raw, protect special chars
+                        if not field in raw_fields :
+                              rstring = self.htmlize(rstring)
+                        string += '<td>%s</td>' % rstring
+                  string += '</tr>\n'
+            # close the table
+            string += '</table>\n'
+            return string
+      def record_form_entries(self, index, fields, lfields, record, MSDSs=None, entry_width=ENTRY_WIDTH):
+            """
+            >>> print record_form_entries(4,['A','B','MSDS'],{'A':'AA','B':'BB','MSDS'},{'A':'a','B':'b'}),
+            <table>
+             <tr><td><label for="A">AA</label></td><td>:</td>
+              <td><input type="text" size="50" id="A" name="A" value="a"></td></tr>
+             <tr><td><label for="B">BB</label></td><td>:</td>
+              <td><input type="text" size="50" id="B" name="B" value="b"></td></tr>
+             <tr><td><label for="MSDS">MSDS</label></td><td>:</td>
+              <td><input type="file" size="50" id="MSDS" name="MSDS" value=""></td></tr>
+            </table>
+            """
+            # open the table
+            string = '<table>\n'
+            # add the record fields
+            for field in fields :
+                  # get the record string
+                  rstring = self.htmlize(record[field])
+                  fstring = self.htmlize(field)
+                  lfstring = self.htmlize(lfields[field])                  
+                  string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' % (fstring, lfstring)
+                  string += '  <td><input type="text" size="%d" id="%s" name="%s" value="%s"></td></tr>\n' \
+                            % (entry_width, fstring, fstring, rstring)
+            if MSDSs != None :
+                  ## add an MSDS radio, share menu, and file upload fields
+                  # radio
+                  lfstring = fstring = 'MSDS source'
+                  string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
+                            % (fstring, lfstring)
+                  string += '  <td><input type="radio" id="%s" name="%s" value="upload" checked>Upload\n' \
+                            % (fstring, fstring)
+                  string += '      <input type="radio" id="%s" name="%s" value="share">Share</td></tr>\n' \
+                            % (fstring, fstring)
+
+                  # file upload
+                  fstring = 'MSDS'
+                  lfstring = 'Upload MSDS'
+                  string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
+                            % (fstring, lfstring)
+                  string += '  <td><input type="file" size="%d" id="%s" name="%s" accept="text/plain,text/html,application/pdf" value=""></td></tr>\n' \
+                            % (entry_width, fstring, fstring)
+
+                  # file share menu
+                  fstring = 'MSDS share'
+                  lfstring = 'Use an already uploaded MSDS'
+                  string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
+                            % (fstring, lfstring)
+                  string += '  <td><select size="1" id="%s" name="%s">\n' \
+                            % (fstring, fstring)
+                  for MSDS in MSDSs :
+                        lfstring = self.htmlize(MSDS['Name'])
+                        id = int(MSDS['ID'])
+                        string += '   <option value="%d">%d: %s</option>\n' \
+                                  % (id, id, lfstring)
+                  string += '   </select></td></tr>\n'
+            # close the table
+            string += '</table><br>\n'
+            return string
+
+class templt (object) :
+     "Handle some template wrapping"
+     def __init__(self) :
+           self.dir = 'templates/'
+           if not os.path.exists(self.dir): os.mkdir(self.dir)
+           ## Write simple template html pages if they don't already exist.
+           # see ( http://webpy.org/templetor ) for the syntax.
+           html=('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"\n'
+                 ' "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">\n'
+                 '<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">\n')
+           if not os.path.exists(self.dir+'view.html') :
+                 view=('$def with (name, next_db_id, fields, records)\n'
+                       +html+
+                       ' <head>\n'
+                       '  <title>\n'
+                       '   $:name2str(name) \n'
+                       '  </title>\n'
+                       ' </head>\n'
+                       ' <body>\n'
+                       '  <h1>\n'
+                       '   $:name2str(name)\n'
+                       '  </h1>\n'
+                       '  See \n'
+                       '   <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm">the rules</a>\n'
+                       '   for more information.\n'
+                       '  See the <a href="/docs/">docs</a> page to generate required documents.<br>\n'
+                       '  <br>\n'
+                       '  $:make_add_button(next_db_id)\n'
+                       '  $:make_table(fields, records)\n'
+                       '  $:make_add_button(next_db_id)\n'
+                       ' </body>\n'
+                       '</html>\n')
+                 file(self.dir+'view.html', 'w').write(view)
+           if not os.path.exists(self.dir+'edit.html') :
+                 # note:
+                 # the form encoding type 'enctype="multipart/form-data">\n'
+                 # is only required because of the MSDS file-upload field.
+                 edit=('$def with (index, fields, lfields, record, MSDSs)\n'
+                       +html+
+                       ' <head>\n'
+                       '  <title>\n'
+                       '   $:htmlize(record["Name"]) \n'
+                       '  </title>\n'
+                       ' </head>\n'
+                       ' <body>\n'
+                       '  <a style="cursor:pointer;" onclick="history.back()"><h1>\n'
+                       '   Editing: $:htmlize(record["Name"]) \n'
+                       '  </h1></a>\n'
+                       '  <form action="" method="post" enctype="multipart/form-data">\n'
+                       '   $:record_form(index, fields, lfields, record, MSDSs)<br />\n'
+                       '   <input type="submit" value="Submit" />\n'
+                       '  </form>\n'
+                       ' </body>\n'
+                       '</html>\n')
+                 file(self.dir+'edit.html', 'w').write(edit)
+           if not os.path.exists(self.dir+'error.html') :
+                 # like view, but no edit ability
+                 #  since the content is generated,
+                 # or backlink list, since there would be so many.
+                 stat=('$def with (id)\n'
+                       +html+
+                       ' <head>\n'
+                       ' </head>\n'
+                       ' <body>\n'
+                       '  <h1>Error</h1>\n'
+                       '  There is currently no MSDS file for \n'
+                       '  $:htmlize(id)\n'
+                       ' </body>\n'
+                       '</html>\n')
+                 file(self.dir+'error.html', 'w').write(stat)
+           if not os.path.exists(self.dir+'docs.html') :
+                 docs=(html +
+                       '<a href="/docs/inventory.pdf">Inventory</a>\n'
+                       ' in accordance with the \n'
+                       ' <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">\n'
+                       '  Chemical Hygiene Plan Section E-7</a>.<br>\n'
+                       '<a href="/docs/door_warning.pdf">Door warning</a>\n'
+                       ' in accordance with the Chemical Hygiene Plan Sections\n'
+                       ' <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">E-7</a>\n'
+                       ' and \n'
+                       ' <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe10">E-10</a>\n'
+                       '.<br>\n'
+                       ' </body>\n'
+                       '</html>\n')
+                 file(self.dir+'docs.html', 'w').write(docs)
+
+
+class chem_web_daemon (daemon.Daemon) :
+      gid = None
+      uid = None
+      pidfile = PID_FILE
+      logfile = LOG_FILE
+      loglevel = 'INFO'
+      def run(self) :
+            if self.pkey_file == None or self.cert_file == None :
+                  logging.info("http://%s:%d/" % web.validip(self.options.ip))
+            else :
+                  logging.info("https://%s:%d/" % web.validip(self.options.ip))
+
+            ## web.py should give detailed error messages, not just `internal error'
+            web.internalerror = web.debugerror
+
+            ## How we'd start webpy server if we didn't need command line args or SSL
+            # Pass web my URL regexps, and the globals from this script
+            # You can also pass it web.profiler to help optimize for speed
+            #web.run(urls, globals())
+            ## How we have to start it now
+            webpy_func = web.webpyfunc(urls, globals(), False)
+            wsgi_func = web.wsgifunc(webpy_func)
+            web.httpserver.runsecure(wsgi_func,
+                                     web.validip(self.options.ip),
+                                     ssl_private_key_filename=self.pkey_file,
+                                     ssl_certificate_filename=self.cert_file)
+
+      def read_basic_config(self) :
+            pass
+      def parse_options(self):
+            from optparse import OptionParser
+            
+            usage_string = ('%prog [options]\n'
+                            '\n'
+                            '2008, W. Trevor King.\n')
+            version_string = '%%prog %s' % __version__
+            parser = OptionParser(usage=usage_string, version=version_string)
+            
+            # Daemon options
+            parser.add_option('--start', dest='action',
+                              action='store_const', const='start', default='start',
+                              help='Start the daemon (the default action)')
+            parser.add_option('--stop', dest='action',
+                              action='store_const', const='stop', default='start',
+                              help='Stop the daemon')
+            parser.add_option('-n', '--nodaemon', dest='daemonize',
+                              action='store_false', default=True,
+                              help='Run in the foreground')
+            
+            # Server options
+            parser.add_option('-a', '--ip-address', dest="ip",
+                              help="IP address (default '%default')",
+                              type='string', metavar="IP", default='0.0.0.0:8080')
+            parser.add_option('-s', '--secure', dest="secure",
+                              help="Run in secure (HTTPS) mode.",
+                              type='string', metavar="PKEY_FILE:CERT_FILE")
+            parser.add_option('-v', '--verbose', dest="verbose", action="store_true",
+                              help="Print lots of debugging information",
+                              default=False)
+            
+            self.options, self.args = parser.parse_args()
+            parser.destroy()
+            
+            if self.options.verbose :
+                  self.loglevel = 'DEBUG'
+            # get options for httpserver
+            if self.options.secure != None :
+                  split = self.options.secure.split(':')
+                  assert len(split) == 2, "Invalid secure argument '%s'"
+                  self.pkey_file = split[0]
+                  self.cert_file = split[1]
+            else :
+                  self.pkey_file = None
+                  self.cert_file = None
+
+### the following instances and definitions must have
+## global scope. because they are called from inside web
+# create the database
+db = database(filename=DB_FILE)
+MSDSman = MSDS_manager(db)
+dgen = docgen(db)
+
+## set up the templates
+tmplt = templt()
+# Tell templator where to look for templates
+# to provide a framework for the generated webpages
+render = web.template.render(tmplt.dir)
+
+## Define markup functions
+mkup = markup()
+htmlize = lambda t : mkup.htmlize(t)
+name2str = lambda n : mkup.name2str(n)
+mktable = lambda h,r : mkup.make_table(h,r,WIDTH_MAX,raw_fields=('ID','Name'))
+mkadd = lambda ni : mkup.make_add_button(ni)
+record_form = lambda i,f,l,r,M : mkup.record_form_entries(i,f,l,r,M)
+## Give templates access to the htmlize, make_table, and name2str functions
+# ( see http://webpy.org/templetor and our view.html )
+web.template.Template.globals['htmlize'] = htmlize
+web.template.Template.globals['make_table'] = mktable
+web.template.Template.globals['make_add_button'] = mkadd
+web.template.Template.globals['name2str'] = name2str
+web.template.Template.globals['record_form'] = record_form
+
+
+## If this script is run from the command line,
+# use the tools we've just defined to host the chemical inventory
+if __name__=="__main__":
+      chem_web_daemon().main()
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644 (file)
index 0000000..1b02257
--- /dev/null
@@ -0,0 +1,167 @@
+# Produce html, pdf, and dvi output from latex source
+# W. Trevor King, 2008, version 0.2
+# This file is in the public domain.
+#
+# exposed targets :
+#  all          : generate each of the outputs (pdf, html, dvi) and call view
+#  view         : call '$(PDF_VIEWER) main.pdf &' to see the pdf file
+#  install      : call install_% for pdf, html, dvi, and dist
+#  clean        : semi-clean and remove the $(GENERATED_FILES) as well
+#  semi-clean   : remove all intermediate $(TEMP_FILES) used during generation
+#
+#  pdf          : generate the pdf file (Portable Document Format)
+#  install_pdf  : install the dvi file (currently no action)
+#  html         : generate html directory ready for posting
+#  install_html : scp the html directory to $(INSTALL_HTML)
+#  dvi          : generate main.dvi (DeVice Independent file)
+#  install_dvi  : install the dvi file (currently no action)
+#  dist         : gen. $(DOC_NAME)-$(VERSION).tar.gz containing $(DIST_FILES)
+#  install_dist : install the dist file (currently no action)
+#
+#  images       : call for generation of all images in $(IMAGES)
+
+DOC_NAME = chem_inventory
+VERSION = 0.2
+
+# I like to keep my images in seperate directories, where any processing
+# they may need woln't clutter up the base directory with temp files.
+# To control these, this Makefile calls the Makefiles in the IMAGE_DIRS,
+# so make sure they exist, and have the following targets:
+#  clean      : remove all generated files
+#  semi-clean : remove all intermediate files
+# for each image in IMAGE, you must have a rule that generates it,
+# see 'image generation rules' below
+IMAGES = mp/NFPA.1
+IMAGE_DIRS = mp
+
+# Non image source files
+SOURCE_FILES = main.tex *.tex
+# And anything else you'd like to distribute
+OTHER_FILES = README Makefile
+
+DIST_FILES = $(SOURCE_FILES) $(OTHER_FILES) $(IMAGE_DIRS)
+
+# Select where to put things when you call the dist and install targets
+DIST_DIR = $(DOC_NAME)-$(VERSION)
+DIST_NAME = $(DOC_NAME)-$(VERSION).tar.gz
+INSTALL_PDF = $(HOME)/rsrch/notes
+INSTALL_DVI = 
+INSTALL_HTML = einstein:./public_html/rsrch/papers/$(DOC_NAME)
+# interpreted as latex by latex2html, so escape the '~' to '\~{}' 
+TILDE = "%7E"
+CSS_PATH = "/$(TILDE)wking/shared/style_l2h.css"
+
+# Files removed on clean or semi-clean
+TEMP_FILES = *[.]aux *[.]log *[.]out *[.]bak \
+       *.png *.eps
+# Files removed only on clean
+GENERATED_FILES = $(DIST_NAME) \
+       rm -f main.pdf main.dvi html *.pdf
+
+PDF_VIEWER = xpdf # you can also try evince, acroread, 'xpdf -view H', etc. 
+
+## image generation rules, to handle the images in $(IMAGES)
+
+mp/NFPA.1 : 
+       $(MAKE) -C mp images
+
+
+
+### The remaining rules shouldn't need to be changed
+
+# pdf, dvi, and html depend on images
+images : $(IMAGES)
+
+
+## Big targets
+all : pdf html dvi view
+
+view : pdf
+       $(PDF_VIEWER) main.pdf &
+
+install : install_pdf install_html install_dvi install_dist
+
+clean : semi-clean $(IMAGE_DIRS:%=%_clean)
+       rm -rf $(GENERATED_FILES)
+
+semi-clean : $(IMAGE_DIRS:%=%_semi-clean)
+       rm -f $(TEMP_FILES)
+
+
+## Mid-level indirection targets
+
+# indirection, so we can have short names like 'pdf'
+# without rebuilding when it's not neccessary
+pdf : main.pdf
+dvi : main.dvi
+dist : $(DIST_NAME)
+
+
+## Installation targets
+
+install_pdf :
+install_html : html clean_html_image_dirs
+       scp -r html $(INSTALL_HTML)
+install_dvi :
+install_dist :
+
+
+## The meat of the generation
+
+# generate the pdf output directly with pdflatex
+main.pdf : main.aux $(SOURCE_FILE) images
+       pdflatex main.tex
+
+# generate html files, including the DIST_FILES in a subdir.
+# latex2html requires main.aux to generate figure numbers
+# main.pdf is for the '$(DOC_NAME).pdf' link in the footer
+# (see ~/.latex2html-init) 
+html : $(SOURCE_FILES) main.aux main.pdf
+       latex2html -split 1 -white -notransparent -html_version 3.2 \
+           -t $(DOC_NAME) -dir html -mkdir \
+           -noshort_extn -top_navigation -bottom_navigation \
+            -up_url '../' -up_title 'Papers' \
+           -show_section_numbers \
+           -style $(CSS_PATH) \
+           main.tex    # generate the html
+       if [ ! -d html/src ]; then  mkdir html/src ; fi # mk src if it doesn't exist yet
+       cp -r $(DIST_FILES) html/src/     # move the source files into html/src 
+       cp main.pdf html/$(DOC_NAME).pdf  # move the pdf in so we can link to it
+
+# generate the .aux file which contains labels for referencing
+# needed to correctly number references in the pdf and html output.
+# (Clash between latex and pdflatex's .aux files? semi-clean fixes)
+# -draftmode makes pdflatex ignore images and not make a pdf (faster)
+main.aux : *.tex images
+       pdflatex -draftmode main.tex
+
+# to stretch our .tex files a bit more, make a dvi as well :p
+# run twice to straighten out and TOC or references issues
+main.dvi : $(SOURCE_FILES) images
+       latex main.tex
+       latex main.tex
+
+# generate a gzipped tar file containing the DIST_FILES
+$(DIST_NAME) : $(DIST_FILES)
+       mkdir $(DIST_DIR)
+       cp $(DIST_FILES) $(DIST_DIR)
+       tar -chozf $(DIST_NAME) $(DIST_DIR)
+       rm -rf $(DIST_DIR)
+
+
+## IMAGE_DIR handling rules
+
+# call clean for any image directory
+%_clean : %
+       $(MAKE) -C $< clean
+
+# call semi-clean for any image directory
+%_semi-clean : %
+       $(MAKE) -C $< semi-clean
+
+# clean and junk that was in the working image directories
+clean_html_image_dirs : $(IMAGE_DIRS:%=%_html_dir_clean)
+
+# call clean for any image directory in the html/src directory
+%_html_dir_clean : %
+       $(MAKE) -C html/src/$< clean
diff --git a/docs/README b/docs/README
new file mode 100644 (file)
index 0000000..338876a
--- /dev/null
@@ -0,0 +1,7 @@
+The bulk of the content is generated dynamically from the database.
+This directory just contains the latex and infrastructure to make nice
+documents from the data.
+
+The `mp' directory contains metapost source and assorted
+infrastructure for building and previewing the graphics.
+
diff --git a/docs/contact.tex b/docs/contact.tex
new file mode 100644 (file)
index 0000000..8c34a8a
--- /dev/null
@@ -0,0 +1,5 @@
+\begin{tabular}{l}
+  Professor Guoliang Yang\\
+  215-895-6669 \\
+  Disque Hall 926 \\
+\end{tabular}
diff --git a/docs/door_template.tex b/docs/door_template.tex
new file mode 100644 (file)
index 0000000..b669ec5
--- /dev/null
@@ -0,0 +1,54 @@
+% \documentclass[letterpaper]{article} % line moved to main.
+% Paper for posting on the lab door
+% as per 
+
+% setup the margins,
+\topmargin -0.5in
+\headheight 0.0in
+\headsep 0.0in
+\textheight 10in
+\oddsidemargin -0.5in
+\textwidth 7.5in
+
+%% Graphics packages
+
+\usepackage{graphicx}   % to include images
+% if pdftex doesn't recognize the type, it's mps
+% DeclareGraphicsRule{ext}{type}{sizefile}{command}
+\DeclareGraphicsRule{*}{mps}{*}{}
+% latex and latex2html can handle Metapost's eps output without modification
+
+% define a vertical strut for table spacing
+\newcommand{\Tstrut}{\rule{0pt}{2.6ex}}
+\newcommand{\headfont}{\Large}
+\newcommand{\contfont}{\large}
+\newcommand{\vspacer}{\vskip 12pt}
+
+%-----------------------end preamble-------------------------
+
+\begin{document}
+\thispagestyle{empty} % suppress page numbering
+\sffamily % switch to sans-serif
+
+
+\begin{center}
+
+\includegraphics[width=3in]{mp/NFPA.1}
+\vspacer
+
+\contfont
+\input{door_data}
+
+\vfill
+
+{\headfont Contact}
+\vskip 10pt
+\contfont
+\input{contact}
+\vspacer
+
+Generated \today\ by chem\_db.py
+
+\end{center}
+
+\end{document}
diff --git a/docs/inventory_template.tex b/docs/inventory_template.tex
new file mode 100644 (file)
index 0000000..77f5e77
--- /dev/null
@@ -0,0 +1,54 @@
+% \documentclass[letterpaper]{article} % line moved to main.
+% Paper for posting on the lab door
+% as per 
+
+% setup the margins,
+\topmargin -0.5in
+\headheight 0.0in
+\headsep 0.0in
+\textheight 9.5in % leave a bit of extra room for page numbers
+\oddsidemargin -0.5in
+\textwidth 7.5in
+
+%% Graphics packages
+
+\usepackage{graphicx}   % to include images
+% if pdftex doesn't recognize the type, it's mps
+% DeclareGraphicsRule{ext}{type}{sizefile}{command}
+\DeclareGraphicsRule{*}{mps}{*}{}
+% latex and latex2html can handle Metapost's eps output without modification
+
+% N of M style page numbering
+\usepackage{fancyhdr}
+\pagestyle{fancy}
+\usepackage{lastpage}
+\cfoot{\thepage\ of \pageref{LastPage}}
+% Turn off the silly head and foot rules
+\renewcommand{\headrule}{\relax}
+\renewcommand{\footrule}{\relax}
+
+% Break the table across several pages
+% requires 2 latex runs.
+\usepackage{longtable}
+
+% define a vertical strut for table spacing
+\newcommand{\Tstrut}{\rule{0pt}{2.6ex}}
+\newcommand{\headfont}{\Large}
+\newcommand{\contfont}{\large}
+\newcommand{\vspacer}{\vskip 12pt}
+
+%-----------------------end preamble-------------------------
+
+\begin{document}
+
+\begin{center}
+{\headfont Inventory}\\
+\contfont
+Generated \today\ by chem\_db.py\\
+\vskip 10pt
+\end{center}
+
+\footnotesize
+\input{inventory_data}
+
+\end{document}
diff --git a/docs/mp/Makefile b/docs/mp/Makefile
new file mode 100644 (file)
index 0000000..511e5ca
--- /dev/null
@@ -0,0 +1,30 @@
+MPS = NFPA
+
+TEMP_FILES = $(MPS:%=%.mp[a-z]) $(MPS:%=%.log) mp* texnum.mpx
+GENERATED_FILES = $(MPS:%=%.[0-9]*) sample.log sample.aux sample.pdf
+
+#PDFVIEWER = gv
+PDFVIEWER = xpdf
+#PDFVIEWER = evince
+
+images : $(MPS:%=%.1)
+
+all : view
+
+view : sample.pdf
+       $(PDFVIEWER) sample.pdf &
+
+clean : semi-clean
+       rm -f $(GENERATED_FILES)
+
+semi-clean :
+       rm -f $(TEMP_FILES)
+
+# generate a pdf containing all mp images for previewing-troubleshooting
+# depend on the first image from each mp file
+sample.pdf : sample.tex images
+       pdflatex sample.tex
+
+# if we call for the first image from an mp file, make them all
+%.1 : %.mp %_c.mp
+       mpost "\$(^:%=input %;)"
diff --git a/docs/mp/NFPA_c.mp b/docs/mp/NFPA_c.mp
new file mode 100644 (file)
index 0000000..53cf421
--- /dev/null
@@ -0,0 +1,70 @@
+
+%% NFPA warning triangle
+boolean labels;
+labels := false;             % turn on debugging labels
+
+% sizing
+numeric u;
+
+u := 2cm;               % unit, for overall scaling
+
+% line thicknesses
+numeric cyl_thickness, wire_thickness;
+border := 1pt;  % thickness of the box outlines
+
+% colors
+color hcolor, fcolor, rcolor, ocolor, bcolor;
+hcolor := (0,0,1); % health
+fcolor := (1,0,0); % fire
+rcolor := (1,1,0); % reactivity
+ocolor := white;   % other
+bcolor := black;   % border
+
+% return a diamond path
+vardef diamond(expr radius) =
+  save P;
+  path P;
+  P := (-radius,0) -- (0,radius) -- (radius,0) -- (0,-radius) -- cycle;
+  P
+enddef;
+
+def NFPA(expr center, radius, h, f, r, o) =
+  save p, hr, qr;
+  pair p;
+  numeric hr, hr;
+  hr := radius/2;
+  qr := radius/4;
+  % draw the background colors
+  fill diamond(hr) shifted (center-(hr,0)) withcolor hcolor;
+  fill diamond(hr) shifted (center+(0,hr)) withcolor fcolor;
+  fill diamond(hr) shifted (center+(hr,0)) withcolor rcolor;
+  fill diamond(hr) shifted (center-(0,hr)) withcolor ocolor;
+  % draw the borders
+  draw diamond(radius) shifted center withpen pencircle scaled border
+                                      withcolor bcolor;
+  p := (hr,hr);
+  draw p -- -p shifted center         withpen pencircle scaled border
+                                      withcolor bcolor;
+  p := (hr,-hr);
+  draw p -- -p shifted center         withpen pencircle scaled border
+                                      withcolor bcolor;
+  % add the text
+  label(h, center-(hr,0));
+  label(f, center+(0,hr));
+  label(r, center+(hr,0));
+  % btex 4 etex,
+  % btex ox etex;
+  % btex \sout{W} etex;
+  label(o, center-(0,hr));
+enddef;
+
+beginfig(1)
+  NFPA(origin, u,
+    h,
+    f,
+    r,
+    o);
+
+endfig;
+
+end
diff --git a/docs/mp/README b/docs/mp/README
new file mode 100644 (file)
index 0000000..d09f0c1
--- /dev/null
@@ -0,0 +1,13 @@
+NFPA.mp and NFPA_c.mp are for generating an NFPA warning diamond.  The
+diamond code and figure definitions are in NFPA_c.mp (c for code :p).
+NFPA.mp extracts the TeX for creating the symbols for the corners.
+Because this infomation will change with time (depending on the
+chemicals in the lab), you have to recreate NFPA.mp to get an image
+with the current values.  Calling
+ $ gen_NFPA.sh <H> <F> <R> <ox?> <w?> > NFPA.mp
+will generate the proper NFPA.mp file.  As an explicit example,
+try the complete pdf generation and viewing commands
+ $ gen_NFPA.sh 4 3 3 y y > NFPA.mp
+ $ make view
+NFPA_c.mp contains the common code for drawing warning diamonds.
+sample.tex is just a simple latex script for previewing diamonds.
diff --git a/docs/mp/gen_NFPA.sh b/docs/mp/gen_NFPA.sh
new file mode 100755 (executable)
index 0000000..8057467
--- /dev/null
@@ -0,0 +1,76 @@
+#!/bin/bash
+#
+# Generate NFPA.mp for given hazard levels
+
+HEALTH=$1
+FIRE=$2
+REACT=$3
+OX=$4
+W=$5
+
+REDIRECT=0 # if != 0, then print directly to OFILE
+OFILE="NFPA.mp"
+
+# stdout redirection from Advanced Bash Scripting Guide
+# http://tldp.org/LDP/abs/html/x16355.html#REASSIGNSTDOUT
+if [ $REDIRECT -ne 0 ]
+then
+    exec 6>&1  # Link file descriptor #6 with stdout.
+               # Saves stdout.
+    # stdout replaced with file "logfile.txt".
+    exec > $OFILE
+fi
+
+echo "verbatimtex
+
+\font\bigfont=cmss17 at 50pt
+\font\medfont=cmss17 at 25pt
+\font\smfont=cmss17 at 20pt
+\bigfont
+
+% center two hboxes (containing e.g. \OX and \W)
+\def\vert#1#2{\vbox{\halign{\hfil##\hfil\cr
+\hbox{#1}\cr
+\hbox{#2}\cr}}}
+
+% strikethrough (or strikeout) from Chapter 21 of the TeXbook
+% the height of the strikeout has been raised from 0.8 to 0.9ex
+% to make it look nicer when escaping a purely capital letter (W).
+\def\sout#1{{%
+  \setbox0=\hbox{#1}%
+  \dimen0 0.9ex\dimen1\dimen0\advance\dimen1 by 0.4pt
+  \rlap{\leaders\hrule height \dimen1 depth -\dimen0\hskip\wd0}%
+  \box0
+}}
+
+\def\OX{{OX}}
+\def\W{{\sout{W}}}
+
+etex
+
+%% NFPA values :
+% set in the call to mpost, e.g.
+% $ mpost 'input NFPA.mp; input NFPA_c.mp'
+picture h,f,r,o;
+h := btex $HEALTH etex;
+f := btex $FIRE etex;
+r := btex $REACT etex;"
+
+if [ "$OX" == "y" ] && [ "$W" == "y" ]
+then
+    echo "o := btex {\smfont \vert{\OX}{\W}} etex;"
+elif [ "$OX" == "y" ]
+then
+    echo "o := btex {\medfont \OX} etex;"
+elif [ "$W" == "y" ]
+then
+    echo "o := btex {\medfont \W} etex;"
+fi
+
+if [ $REDIRECT -ne 0 ]
+then
+    # Restore stdout and close file descriptor #6.
+    exec 1>&6 6>&-
+fi
+
+exit 0
diff --git a/docs/mp/sample.tex b/docs/mp/sample.tex
new file mode 100644 (file)
index 0000000..90dc3e7
--- /dev/null
@@ -0,0 +1,13 @@
+\documentclass{article}
+\usepackage{graphicx}
+\usepackage{fullpage}
+
+\DeclareGraphicsRule{*}{mps}{*}{} % if tex doesn't recognize the type, it's mps
+
+% NFPA for 'NFPA.mp image'
+\newcommand{\NFPA}[1]{piezo.#1 \includegraphics{NFPA.#1}\vspace{1cm} \\}
+
+\begin{document}
+\centering
+\NFPA{1}
+\end{document}
diff --git a/examples/inventory.db b/examples/inventory.db
new file mode 100644 (file)
index 0000000..0868db7
--- /dev/null
@@ -0,0 +1,5 @@
+#ID    Name    Amount  CAS#    Cat#    Vendor  Recieved        Location        H       F       R       O       M       C       T       Disposed        Note
+#ID    Name    Amount  CAS#    Catalog #       Vendor  Date Recieved   Location        NFPA Health     NFPA Fire       NFPA Reactivity NFPA Other      Mutagen ('M' or '')     Carcinogen ('C' or '')  Teratogen ('T' or '')   Date disposed   Note
+0      Acetic Acid     500 ml  64-19-7 A35-500 Fisher  7/5/2001        acidic liquids  3       2       1                                               
+1      Glycine 500 g   56-40-6 15527-013       Life Tech.      1/16/2000       non-hazardous   1       0       0                                               Avoid strong oxidizers
+2      Sodium Chloride (NaCl)  500 g   7647-14-5       JT4058-1        VWR/Fisher      10/22/2000      non-hazardous   1       0       0                                               
diff --git a/mk_simple_certs.py b/mk_simple_certs.py
new file mode 100644 (file)
index 0000000..ecff515
--- /dev/null
@@ -0,0 +1,35 @@
+"""
+From pyOpenSSL examples with a bit of wrapping.
+Create certificates and private keys for the 'simple' example.
+"""
+
+from OpenSSL import crypto
+from certgen import *   # yes yes, I know, I'm lazy
+
+def get_cert_filenames(server_name) :
+    """
+    Generate private key and certification filesnames.
+    mk_certs(server_name) -> (pkey_filename, cert_filename)
+    """
+    pkey_file = '%s.pkey' % server_name
+    cert_file = '%s.cert' % server_name
+    return (pkey_file, cert_file)
+
+def mk_certs(server_name) :
+    """
+    Generate private key and certification files.
+    mk_certs(server_name) -> (pkey_filename, cert_filename)
+    """
+    pkey_file,cert_file = get_cert_filenames(server_name)
+    
+    cakey = createKeyPair(TYPE_RSA, 1024)
+    careq = createCertRequest(cakey, CN='Certificate Authority')
+    cacert = createCertificate(careq, (careq, cakey), 0, (0, 60*60*24*365*5)) # five years
+    open(pkey_file, 'w').write(crypto.dump_privatekey(crypto.FILETYPE_PEM, cakey))
+    open(cert_file, 'w').write(crypto.dump_certificate(crypto.FILETYPE_PEM, cacert))
+
+if __name__ == "__main__" :
+    import sys
+
+    mk_certs(sys.argv[1])
+
diff --git a/text_db.py b/text_db.py
new file mode 100644 (file)
index 0000000..bc9f20d
--- /dev/null
@@ -0,0 +1,491 @@
+#!/usr/bin/python
+"""
+Simple database-style interface to text-delimited, single files.
+Use this if, for example, your coworkers insist on keeping data compatible with M$ Excel.
+"""
+
+import copy
+from sys import stdin, stdout, stderr
+
+# import os, shutil, and chem_db for managing the database files
+import os, shutil, stat, time
+import os.path
+
+FILE = 'default.db'
+STANDARD_TAB = 8
+
+class fieldError (Exception) :
+    "database fields are not unique"
+    pass
+
+class text_db (object) :
+    """
+    Define a simple database interface for a spread-sheet style database file.
+    
+    field_list()  : return an ordered list of available fields (fields unique)
+    long_fields() : return an dict of long field names (keyed by field names)
+    record(id)    : return a record dict (keyed by field names)
+    records()     : return an ordered list of available records.
+    backup()      : save a copy of the current database somehow.
+    set_record(i, newvals) : set a record by overwriting any preexisting data
+                           : with data from the field-name-keyed dict NEWVALS
+    new_record()  : add a blank record (use set_record to change its values)
+    """
+    def __init__(self, filename=FILE, COM_CHAR='#', FS='\t', RS='\n',
+                 current_dir='./current/', backup_dir='./backup/' ) :
+        self.filename = filename
+        self.COM_CHAR = COM_CHAR # comment character (also signals header row)
+        self.FS = FS             # field seperator
+        self.RS = RS             # record seperator
+        self.db_id_field = "db_id" # add a record field for the database index
+        # define directories used by the database
+        self.cur = current_dir
+        self.bak = backup_dir
+        
+        if self.filename == None :
+            return # for testing, don't touch the file system
+        
+        ## Generate the neccessary directory structure if neccessary
+        for d in [self.cur,self.bak] :
+            self.check_dir(d)
+
+        self._open()
+        
+    # directory and file IO operations
+    def check_dir(self. dir) :
+        "Create the database directory if it's missing"
+        if os.path.isdir(dir) :
+            return # all set to go
+        elif os.path.exists(dir) :
+            raise Exception, "Error: a non-directory file exists at %s" % dir
+        else :
+            os.mkdir(dir)
+    def curpath(self) :
+        "Return the path to the current database file."
+        return os.path.join(self.cur, self.filename)
+    def _get_mtime(self) :
+        "Get the timestamp of the last modification to the database."
+        s = os.stat(self.curpath())
+        return s[stat.ST_MTIME]
+    def exists(self) :
+        "Check if the database exists"
+        # for some reason, my system's os.path.exists
+        # returns false for valid symbolic links...
+        return os.path.exists(self.curpath())
+    def _assert_exists(self) :
+        """
+        Assert that the database exists on disk.
+        Print a reasonable error if it does not.
+        """
+        assert self.exists(), "Missing database file %s" % self.curpath()
+    def _open(self) :
+        "Load the database from disk"
+        self._assert_exists()
+        # precedence AND > OR
+        fulltext = file(self.curpath(), 'r').read()
+        self._mtime = self._get_mtime()
+        self._parse(fulltext)
+    def iscurrent(self) :
+        "Check if our memory-space db is still syncd with the disk-space db."
+        return self._mtime == self._get_mtime()
+    def _refresh(self) :
+        "If neccessary, reload the database from disk."
+        if not self.iscurrent() :
+            self._open()
+    def _save(self) :
+        "Create a new database file from a header and list of records."
+        # save the new text
+        fid = file(self.curpath(), 'w')
+        fid.write( self._file_header_string(self._header) )
+        if self._long_header :
+            fid.write( self._file_header_string(self._long_header) )
+        for record in self._records :
+            fid.write( self._file_record_string(record) )
+        fid.close()
+    def backup(self) :
+        "Back up database file"
+        if not self.exists(): return None # nothing to back up.
+        # Append a timestamp to the file & copy to self.bak
+        # the str() builtin ensures a nice, printable string
+        tname = self.filename+'.'+str(int(time.time()))
+        tpath = os.path.join(self.bak, tname)
+        spath = self.curpath()
+        shutil.copy(spath, tpath)
+        
+    # file-text to memory  operations
+    def _get_header(self, head_line, assert_unique=False,
+                     assert_no_db_id_field=True) :
+        """
+        Parse a header line (starts with the comment character COM_CHAR).
+        
+        Because doctest doesn't play well with tabs, use colons as field seps.
+        >>> db = text_db(FS=':', filename=None)
+        >>> print db._get_header("#Name:Field 1:ID: another field")
+        ['Name', 'Field 1', 'ID', ' another field']
+        >>> try :
+        ...    x = db._get_header("#Name:Field 1:Name: another field", assert_unique=True)
+        ... except fieldError, s :
+        ...    print s
+        fields 0 and 2 both 'Name'
+        """
+        assert len(head_line) > 0, 'empty header'
+        assert head_line[0] == self.COM_CHAR, 'bad header: "%s"' % head_line
+        fields = head_line[1:].split(self.FS)
+        if assert_unique :
+            for i in range(len(fields)) :
+                for j in range(i+1,len(fields)) :
+                    if fields[i] == fields[j] :
+                        raise fieldError, "fields %d and %d both '%s'" \
+                                          % (i,j,fields[i])
+        if assert_no_db_id_field :
+            for i in range(len(fields)) :
+                if fields[i] == self.db_id_field :
+                    raise fieldError, "fields %d uses db_id field '%s'" \
+                                      % (i,fields[i])
+        return fields
+    def _get_fields(self, line, num_fields=None) :
+        """
+        Parse a record line.
+        
+        Because doctest doesn't play well with tabs, use colons as field seps.
+        >>> db = text_db(FS=':', filename=None)
+        >>> print db._get_fields("2-Propanol:4 L:67-63-0:Fisher:6/6/2004",7)
+        ['2-Propanol', '4 L', '67-63-0', 'Fisher', '6/6/2004', '', '']
+        """
+        vals = line.split(self.FS)
+        if num_fields != None :
+            assert len(vals) <= num_fields, "Too many values in '%s'" % line
+            for i in range(len(vals), num_fields) :
+                vals.append('') # pad with empty strings if neccessary
+        return vals
+    def _parse(self, text) :
+        reclines = text.split(self.RS)
+        assert len(reclines) > 0, "Empty database file"
+        self._header = self._get_header(reclines[0], assert_unique=True)
+        self._long_header = None
+        self._records = []
+        if len(reclines) == 1 :
+            return # Only a header
+        # check for a long-header line
+        if reclines[1][0] == self.COM_CHAR :
+            self._long_header = self._get_header(reclines[1])
+            startline = 2
+        else :
+            startline = 1
+        for recline in reclines[startline:] :
+            if len(recline) == 0 :
+                continue # ignore blank lines
+            self._records.append(self._get_fields(recline, len(self._header)))
+            
+            
+    # memory to file-text  operations
+    def _file_record_string(self, record) :
+        """
+        Format record for creating a new database file.
+        
+        Because doctest doesn't play well with tabs, use colons as field seps.
+        >>> db = text_db(FS=':', RS=';', filename=None)
+        >>> rs="2-Propanol:4 L:67-63-0:BPA426P-4:Fisher:6/6/2004:2:3:0"
+        >>> print db._file_record_string( db._get_fields(rs)) == (rs+";")
+        True
+        """
+        return "%s%s" % (self.FS.join(record), self.RS)
+    def _file_header_string(self, header) :
+        """
+        Format header for creating a new database file.
+        """
+        return "%s%s%s" % (self.COM_CHAR, self.FS.join(header), self.RS)
+
+    
+    # nice, stable api for our users
+    def field_list(self) : 
+        "return an ordered list of available fields (fields unique)"
+        return copy.copy(self._header)
+    def long_fields(self) :
+        "return an dict of long field names (keyed by field names)"
+        if self._long_header :
+            return dict(zip(self._header, self._long_header))
+        else : # default to the standard field names
+            return dict(zip(self._header, self._header))
+    def record(self, db_id) :
+        "return a record dict (keyed by field names)"
+        assert type(db_id) == type(1), "id %s not an int!" % str(db_id)
+        assert db_id < len(self._records), "record %d does not exist" % db_id
+        d = dict(zip(self._header, self._records[db_id]))
+        d['db_id'] = db_id
+        return d
+    def records(self) :
+        "return an ordered list of available records."
+        ret = []
+        for id in range(len(self._records)) :
+            ret.append(self.record(id))
+        return ret
+    def len_records(self) :
+        "return len(self.records()), but more efficiently"
+        return len(self._records)
+    def set_record(self, db_id, newvals, backup=True) :
+        """
+        set a record by overwriting any preexisting data
+        with data from the field-name-keyed dict NEWVALS
+        """
+        if backup :
+            self.backup()
+        for k,v in newvals.items() :
+            if k == self.db_id_field :
+                assert int(v) == db_id, \
+                    "don't set the db_id field! (attempted %d -> %d)" \
+                    % (db_id, int(v))
+                continue
+            # get the index for the specified field
+            assert k in self._header, "unrecognized field '%s'" % k
+            fi = self._header.index(k)
+            # overwrite the record value
+            self._records[db_id][fi] = v
+        self._save()
+    def new_record(self, db_id=None) :
+        """
+        create a blank new record and return it.
+        """
+        record = {}
+        for field in self._header :
+            record[field] = ""
+        record[self.db_id_field] = len(self._records)
+        self._records.append(['']*len(self._header))
+        return record
+
+class indexStringError (Exception) :
+    "invalid index string format"
+    pass
+
+
+class db_pretty_printer (object) :
+    """
+    Define some pretty-print functions for text_db objects.
+    """
+    def __init__(self, db) :
+        self.db = db
+    def _norm_active_fields(self, active_fields_in=None) :
+        """
+        Normalize the active field parameter
+        
+        >>> db = text_db(FS=':', RS=' ; ', filename=None)
+        >>> pp = db_pretty_printer(db)
+        >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher")
+        >>> print pp._norm_active_fields(None) == {'Name':True, 'Amount':True, 'CAS#':True, 'Vendor':True}
+        True
+        >>> print pp._norm_active_fields(['Vendor', 'Amount']) == {'Name':False, 'Amount':True, 'CAS#':False, 'Vendor':True}
+        True
+        >>> print pp._norm_active_fields('1:3') == {'Name':False, 'Amount':True, 'CAS#':True, 'Vendor':False}
+        True
+        """
+        if active_fields_in == None :
+            active_fields = {}
+            for field in self.db.field_list() :
+                active_fields[field] = True
+            return active_fields
+        elif type(active_fields_in) == type('') :
+            active_i = self._istr2ilist(active_fields_in)
+            active_fields = {}
+            fields = self.db.field_list()
+            for i in range(len(fields)) :
+                if i in active_i :
+                    active_fields[fields[i]] = True
+                else :
+                    active_fields[fields[i]] = False
+        else :
+            if type(active_fields_in) == type([]) :
+                active_fields = {}
+                for field in active_fields_in :
+                    active_fields[field] = True
+            elif type(active_fields_in) == type({}) :
+                active_fields = active_fields_in
+            assert type(active_fields) == type({}), 'by this point, should be a dict'
+            for field in self.db.field_list() :
+                if not field in active_fields :
+                    active_fields[field] = False
+        return active_fields
+    def full_record_string(self, record, active_fields=None) :
+        """
+        Because doctest doesn't play well with tabs, use colons as field seps.
+        >>> db = text_db(FS=':', RS=' ; ', filename=None)
+        >>> pp = db_pretty_printer(db)
+        >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher")
+        >>> print pp.full_record_string( db.record(0) ),
+          Name : 2-Propanol
+        Amount : 4 L
+          CAS# : 67-63-0
+        Vendor : Fisher
+        """
+        fields = self.db.field_list()
+        long_fields = self.db.long_fields()
+        active_fields = self._norm_active_fields(active_fields)
+        # scan through and determine the width of the largest field
+        w = 1
+        for field in fields :
+            if active_fields[field] and len(field) > w :
+                w = len(field)
+        # generate the pretty-print string
+        string = ""
+        for field in fields :
+            if field in active_fields and active_fields[field] :
+                string += "%*.*s : %s\n" \
+                          % (w, w, long_fields[field], record[field])
+        return string
+    def full_record_string_id(self, id, active_fields=None) :
+        record = self.db.record(id)
+        return self.full_record_string(record, active_fields)
+    def _istr2ilist(self, index_string) :
+        """
+        Generate index lists from assorted string formats.
+        
+        Parse index strings
+        >>> pp = db_pretty_printer('dummy')
+        >>> print pp._istr2ilist('0,2,89,4')
+        [0, 2, 89, 4]
+        >>> print pp._istr2ilist('1:6')
+        [1, 2, 3, 4, 5]
+        >>> print pp._istr2ilist('0,3,6:9,2')
+        [0, 3, 6, 7, 8, 2]
+        """
+        ret = []
+        for spl in index_string.split(',') :
+            s = spl.split(':')
+            if len(s) == 1 :
+                ret.append(int(spl))
+            elif len(s) == 2 :
+                for i in range(int(s[0]),int(s[1])) :
+                    ret.append(i)
+            else :
+                raise indexStringError, "unrecognized index '%s'" % spl
+        return ret
+    def _norm_width(self, width_in=None, active_fields=None, skinny=True) :
+        "Normalize the width parameter"
+        active_fields = self._norm_active_fields(active_fields)
+        if type(width_in) == type(1) or width_in == 'a' : # constant width
+            width = {} # set all fields to this width
+            for field in active_fields.keys() :
+                width[field] = width_in
+        else :
+            if width_in == None :
+                width_in = {}
+            width = {}
+            for field in active_fields.keys() :
+                # fill in the gaps in the current width 
+                if field in width_in :
+                    width[field] = width_in[field]
+                else : # field doesn't exist
+                    if skinny : # set to a fixed width
+                        # -1 to leave room for FS
+                        width[field] = STANDARD_TAB-1
+                    else : # set to automatic
+                        width[field] = 'a'
+        return width
+    def _norm_record_ids(self, record_ids=None) :
+        "Normalize the record_ids parameter"
+        if record_ids == None :
+            record_ids = range(len(self.db.records()))
+        if type(record_ids) == type('') :
+            record_ids = self._istr2ilist(record_ids)
+        stderr.flush()
+        return record_ids
+    def _line_record_string(self, record, width=None, active_fields=None,
+                               FS=None, RS=None, TRUNC_STRING=None) :
+        """
+        Because doctest doesn't play well with tabs, use colons as field seps.
+        >>> db = text_db(FS=':', RS=' ; ', filename=None)
+        >>> pp = db_pretty_printer(db)
+        >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher")
+        >>> print pp._line_record_string_id(0)
+        2-Propa:    4 L:67-63-0: Fisher ; 
+        """
+        fields = self.db.field_list()
+        active_fields = self._norm_active_fields(active_fields)
+        width = self._norm_width(width)
+        if FS == None :
+            FS = self.db.FS
+        if RS == None :
+            RS = self.db.RS
+        for field in fields :
+            if field in active_fields and active_fields[field] :
+                lastfield = field
+        # generate the pretty-print string
+        string = ""
+        for field in fields :
+            if field in active_fields and active_fields[field] :
+                w = width[field]
+                string += "%*.*s" % (w, w, record[field])
+                if field != lastfield :
+                    string += "%s" % (FS)
+        string += RS
+        return string
+    def _line_record_string_id(self, id, width=None, active_fields=None,
+                               FS=None, RS=None, TRUNC_STRING=None) :
+        return self._line_record_string(self.db.record(id),
+                                        width, active_fields, FS, RS,
+                                        TRUNC_STRING)
+    def _get_field_width(self, record_ids, field) :
+        """
+        Return the width of the longest value in FIELD
+        for all the records with db_ids in record_ids.
+        """
+        width = 1
+        for i in record_ids :
+            w = len(self.db.record(i)[field])
+            if w > width :
+                width = w
+        return width
+    def _get_width(self, width_in, active_fields=None, record_ids=None) :
+        """
+        Return the width of the largest value in FIELD
+        for all the records with db_ids in record_ids.
+        """
+        active_fields = self._norm_active_fields(active_fields)
+        width = self._norm_width(width_in, active_fields)
+        record_ids = self._norm_record_ids(record_ids)
+
+        for field in active_fields :
+            if width[field] == 'a' :
+                width[field] = self._get_field_width(record_ids, field)
+        return width
+    def multi_record_string(self, record_ids=None, active_fields=None,
+                            width=None, FS=None, RS=None, COM_CHAR=None,
+                            TRUNC_STRING=None) :
+        """
+        Because doctest doesn't play well with tabs, use colons as field seps.
+        >>> db = text_db(FS=':', RS=' ; ', filename=None)
+        >>> pp = db_pretty_printer(db)
+        >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher")
+        >>> print pp.multi_record_string('0'),
+           Name: Amount:   CAS#: Vendor ; 2-Propa:    4 L:67-63-0: Fisher ; 
+        """
+        if FS == None :
+            FS = self.db.FS
+        if RS == None :
+            RS = self.db.RS
+        if COM_CHAR == None :
+            COM_CHAR = self.db.COM_CHAR
+        active_fields = self._norm_active_fields(active_fields)
+        record_ids = self._norm_record_ids(record_ids)
+        width = self._get_width(width, active_fields, record_ids)
+        # generate the pretty-print string
+        string = ""
+        # print a header line:
+        fields = self.db.field_list()
+        hvals = dict(zip(fields,fields))
+        string += "%s" % self._line_record_string(hvals, width,
+                                                  active_fields, FS, RS,
+                                                  TRUNC_STRING=TRUNC_STRING)
+        # print the records
+        for id in record_ids :
+            string += self._line_record_string_id(id,  width,
+                                                  active_fields, FS, RS,
+                                                  TRUNC_STRING=TRUNC_STRING)
+        return string
+
+
+def _test():
+    import doctest
+    doctest.testmod()
+    
+if __name__ == "__main__" :
+    _test()