Index: /pykota/trunk/conf/pykota.conf.sample
===================================================================
--- /pykota/trunk/conf/pykota.conf.sample (revision 1483)
+++ /pykota/trunk/conf/pykota.conf.sample (revision 1495)
@@ -425,5 +425,5 @@
 # PYKOTAPHASE environment variable.
 # Pre and Post Hooks can be defined either globally, per printer,
-# or both if both are defined, the printer specific hook has
+# or both. If both are defined, the printer specific hook has
 # priority.
 #
@@ -476,2 +476,18 @@
 #posthook: /usr/bin/printenv >/tmp/after
 
+# How should enforcement be done for this printer ?
+#
+# "laxist" is the default if value is not set, and allows users
+# to be over quota on their last job. 
+#
+# "strict" tries to prevent users from ever being over quota.
+#
+# Enforcement can be defined either globally, per printer,
+# or both. If both are defined, the printer specific enforcement 
+# setting has priority. 
+#
+# valid values : "strict" or "laxist"
+#
+# default value
+# enforcement : laxist
+enforcement : strict
Index: /pykota/trunk/pykota/tool.py
===================================================================
--- /pykota/trunk/pykota/tool.py (revision 1492)
+++ /pykota/trunk/pykota/tool.py (revision 1495)
@@ -22,4 +22,8 @@
 #
 # $Log$
+# Revision 1.90  2004/05/24 22:45:49  jalet
+# New 'enforcement' directive added
+# Polling loop improvements
+#
 # Revision 1.89  2004/05/21 22:02:52  jalet
 # Preliminary work on pre-accounting
@@ -379,4 +383,5 @@
         self.smtpserver = self.config.getSMTPServer()
         self.maildomain = self.config.getMailDomain()
+        self.softwareJobPrice = 0.0
         
     def logdebug(self, message) :    
@@ -513,4 +518,5 @@
         user = userpquota.User
         printer = userpquota.Printer
+        enforcement = self.config.getPrinterEnforcement(printer.Name)
         self.logdebug("Checking user %s's quota on printer %s" % (user.Name, printer.Name))
         (policy, dummy) = self.config.getPrinterPolicy(userpquota.Printer.Name)
@@ -524,4 +530,6 @@
         else :    
             pagecounter = int(userpquota.PageCounter or 0)
+            if enforcement == "STRICT" :
+                pagecounter += self.softwareJobSize
             if userpquota.SoftLimit is not None :
                 softlimit = int(userpquota.SoftLimit)
@@ -564,16 +572,23 @@
         group = grouppquota.Group
         printer = grouppquota.Printer
+        enforcement = self.config.getPrinterEnforcement(printer.Name)
         self.logdebug("Checking group %s's quota on printer %s" % (group.Name, printer.Name))
         if group.LimitBy and (group.LimitBy.lower() == "balance") : 
-            if group.AccountBalance <= 0.0 :
+            val = group.AccountBalance
+            if enforcement == "STRICT" : 
+                val -= self.softwareJobPrice # use precomputed size.
+            if val <= 0.0 :
                 action = "DENY"
-            elif group.AccountBalance <= self.config.getPoorMan() :    
+            elif val <= self.config.getPoorMan() :    
                 action = "WARN"
             else :    
                 action = "ALLOW"
         else :
+            val = grouppquota.PageCounter
+            if enforcement == "STRICT" :
+                val += self.softwareJobSize
             if grouppquota.SoftLimit is not None :
                 softlimit = int(grouppquota.SoftLimit)
-                if grouppquota.PageCounter < softlimit :
+                if val < softlimit :
                     action = "ALLOW"
                 else :    
@@ -583,5 +598,5 @@
                     else :    
                         hardlimit = int(grouppquota.HardLimit)
-                        if softlimit <= grouppquota.PageCounter < hardlimit :    
+                        if softlimit <= val < hardlimit :    
                             now = DateTime.now()
                             if grouppquota.DateLimit is not None :
@@ -600,5 +615,5 @@
                     # no soft limit, only a hard one.
                     hardlimit = int(grouppquota.HardLimit)
-                    if grouppquota.PageCounter < hardlimit :
+                    if val < hardlimit :
                         action = "ALLOW"
                     else :      
@@ -643,4 +658,6 @@
             else :    
                 val = float(user.AccountBalance or 0.0)
+                if self.config.getPrinterEnforcement(printer.Name) == "STRICT" : 
+                    val -= self.softwareJobPrice # use precomputed size.
                 if val <= 0.0 :
                     return "DENY"
@@ -820,7 +837,8 @@
     def precomputeJobSize(self) :    
         """Computes the job size with a software method."""
+        self.logdebug("Precomputing job's size with generic PDL analyzer...")
         try :
             parser = pdlanalyzer.PDLAnalyzer(self.jobdatastream)
-            return parser.getJobSize()
+            jobsize = parser.getJobSize()
         except pdlanalyzer.PDLAnalyzerError, msg :    
             # Here we just log the failure, but
@@ -830,4 +848,11 @@
             self.logger.log_message(_("Unable to precompute the job's size with the generic PDL analyzer."), "warn")
             return 0
+        else :    
+            if ((self.printingsystem == "CUPS") \
+                and (self.preserveinputfile is not None)) \
+                or (self.printingsystem != "CUPS") :
+                return jobsize * self.copies
+            else :        
+                return jobsize
             
     def sigterm_handler(self, signum, frame) :
Index: /pykota/trunk/pykota/accounters/software.py
===================================================================
--- /pykota/trunk/pykota/accounters/software.py (revision 1483)
+++ /pykota/trunk/pykota/accounters/software.py (revision 1495)
@@ -22,4 +22,8 @@
 #
 # $Log$
+# Revision 1.3  2004/05/24 22:45:49  jalet
+# New 'enforcement' directive added
+# Polling loop improvements
+#
 # Revision 1.2  2004/05/18 14:49:23  jalet
 # Big code changes to completely remove the need for "requester" directives,
@@ -30,10 +34,8 @@
 #
 #
-#
 
 import sys
 import os
 import popen2
-import tempfile
 from pykota.accounter import AccounterBase, PyKotaAccounterError
 
@@ -41,66 +43,40 @@
     def computeJobSize(self) :    
         """Feeds an external command with our datas to let it compute the job size, and return its value."""
-        temporary = None    
-        if self.filter.inputfile is None :    
-            infile = sys.stdin
-            # we will have to duplicate our standard input
-            temporary = tempfile.TemporaryFile()
-        else :    
-            infile = open(self.filter.inputfile, "rb")
-            
-        # launches software accounter
-        # TODO : USE tempfile.mkstemp() instead ! Needs some work !
-        infilename = tempfile.mktemp()
-        outfilename = tempfile.mktemp()
-        errfilename = tempfile.mktemp()
+        self.filter.logdebug("Launching software accounter %s" % self.arguments)
+        MEGABYTE = 1024*1024
+        self.filter.jobdatastream.seek(0)
+        child = popen2.Popen4(self.arguments)
+        try :
+            data = self.filter.jobdatastream.read(MEGABYTE)    
+            while data :
+                child.tochild.write(data)
+                data = self.filter.jobdatastream.read(MEGABYTE)
+            child.tochild.flush()
+            child.tochild.close()    
+        except (IOError, OSError), msg :    
+            msg = "%s : %s" % (self.arguments, msg) 
+            self.filter.logger.log_message(_("Unable to compute job size with accounter %s") % msg)
+        
+        pagecount = 0
+        try :
+            pagecount = int(child.fromchild.readline().strip())
+        except (AttributeError, ValueError) :
+            self.filter.logger.log_message(_("Unable to compute job size with accounter %s") % self.arguments)
+        except (IOError, OSError), msg :    
+            msg = "%s : %s" % (self.arguments, msg) 
+            self.filter.logger.log_message(_("Unable to compute job size with accounter %s") % msg)
+        child.fromchild.close()
         
         try :
-            # feed it with our data
-            fakeinput = open(infilename, "wb")
-            data = infile.read(256*1024)    
-            while data :
-                fakeinput.write(data)
-                if temporary is not None :
-                    temporary.write(data)
-                data = infile.read(256*1024)
-            fakeinput.close()
-        
-            # launches child process
-            command = "%s <%s >%s 2>%s" % (self.arguments, infilename, outfilename, errfilename)
-            retcode = os.system(command)
-            
-            # check exit status
-            if (os.WIFEXITED(retcode) and not os.WEXITSTATUS(retcode)) or os.stat(errfilename) :
-                # tries to extract the job size from the software accounter's
-                # standard output
-                childoutput = open(outfilename, "r")
-                try :
-                    pagecount = int(childoutput.readline().strip())
-                except (AttributeError, ValueError) :
-                    self.filter.logger.log_message(_("Unable to compute job size with accounter %s") % self.arguments)
-                    pagecount = 0
-                childoutput.close()    
-            else :
-                self.filter.logger.log_message(_("Unable to compute job size with accounter %s") % self.arguments)
-                pagecount = 0
-            os.remove(infilename)
-            os.remove(outfilename)
-            os.remove(errfilename)
-        except IOError, msg :    
-            # TODO : temporary files may remain on the filesystem...
-            msg = "%s : %s" % (self.arguments, msg) 
-            self.filter.logger.log_message(_("Unable to compute job size with accounter %s") % msg)
-            pagecount = 0
-            
-        if temporary is not None :    
-            # this is a copy of our previous standard input
-            # flush, then rewind
-            temporary.flush()
-            temporary.seek(0, 0)
-            # our temporary file will be used later if the
-            # job is allowed.
-            self.filter.inputfile = temporary
-        else :
-            infile.close()
+            retcode = child.wait()
+        except OSError, msg :    
+            self.filter.logger.log_message(_("Problem while waiting for software accounter pid %s to exit") % child.pid)
+        else :    
+            if os.WIFEXITED(retcode) :
+                status = os.WEXITSTATUS(retcode)
+            else :    
+                status = retcode
+            self.filter.logger.log_message(_("Software accounter %s exit code is %s") % (self.arguments, repr(retcode)))
+        self.filter.logdebug("Software accounter %s said job is %s pages long." % (self.arguments, pagecount))
         return pagecount    
             
Index: /pykota/trunk/pykota/accounters/hardware.py
===================================================================
--- /pykota/trunk/pykota/accounters/hardware.py (revision 1494)
+++ /pykota/trunk/pykota/accounters/hardware.py (revision 1495)
@@ -22,4 +22,8 @@
 #
 # $Log$
+# Revision 1.4  2004/05/24 22:45:49  jalet
+# New 'enforcement' directive added
+# Polling loop improvements
+#
 # Revision 1.3  2004/05/24 14:36:40  jalet
 # Revert to old polling loop. Will need optimisations
@@ -44,5 +48,4 @@
         """Initializes querying accounter."""
         AccounterBase.__init__(self, kotabackend, arguments)
-        self.isDelayed = 1 # With the pykota filter, accounting is delayed by one job
         
     def getPrinterInternalPageCounter(self) :    
@@ -92,16 +95,4 @@
         return jobsize
         
-    def doAccounting(self, userpquota) :
-        """Does print accounting and returns if the job status is ALLOW or DENY."""
-        # Get the page counter directly from the printer itself
-        counterbeforejob = self.getPrinterInternalPageCounter() or 0
-        
-        # Is the current user allowed to print at all ?
-        action = self.filter.warnUserPQuota(userpquota)
-        
-        # adds the current job to history    
-        userpquota.Printer.addJobToHistory(self.filter.jobid, userpquota.User, counterbeforejob, action, filename=self.filter.preserveinputfile, title=self.filter.title, copies=self.filter.copies, options=self.filter.options)
-        return action
-        
     def askPrinterPageCounter(self, printer) :
         """Returns the page counter from the printer via an external command.
Index: /pykota/trunk/pykota/accounter.py
===================================================================
--- /pykota/trunk/pykota/accounter.py (revision 1483)
+++ /pykota/trunk/pykota/accounter.py (revision 1495)
@@ -22,4 +22,8 @@
 #
 # $Log$
+# Revision 1.15  2004/05/24 22:45:49  jalet
+# New 'enforcement' directive added
+# Polling loop improvements
+#
 # Revision 1.14  2004/05/18 14:49:19  jalet
 # Big code changes to completely remove the need for "requester" directives,
@@ -84,5 +88,4 @@
         self.filter = kotafilter
         self.arguments = arguments
-        self.isDelayed = 0      # Accounting is immediate by default
         self.firstPassSize = None
         
@@ -138,29 +141,7 @@
             return 0
         
-    def doAccounting(self, userpquota) :
-        """Does accounting for current job."""
-        self.beginJob(userpquota)
-            
-        # Is the current user allowed to print at all ?
-        action = self.filter.warnUserPQuota(userpquota)
-        
-        # update the quota for the current user on this printer, if allowed to print
-        if action == "DENY" :
-            jobsize = 0
-        else :    
-            # get the job size
-            jobsize = self.getJobSize()
-            userpquota.increasePagesUsage(jobsize)
-        
-        # adds the current job to history    
-        jobprice = userpquota.computeJobPrice(jobsize)
-        userpquota.Printer.addJobToHistory(self.filter.jobid, userpquota.User, self.getLastPageCounter(), action, jobsize, jobprice, self.filter.preserveinputfile, self.filter.title, self.filter.copies, self.filter.options)
-        self.endJob(userpquota)
-        return action
-        
     def computeJobSize(self) :    
         """Must be overriden in children classes."""
         raise RuntimeError, "AccounterBase.computeJobSize() must be overriden !"
-        
         
 def openAccounter(kotafilter) :
Index: /pykota/trunk/pykota/config.py
===================================================================
--- /pykota/trunk/pykota/config.py (revision 1483)
+++ /pykota/trunk/pykota/config.py (revision 1495)
@@ -22,4 +22,8 @@
 #
 # $Log$
+# Revision 1.48  2004/05/24 22:45:49  jalet
+# New 'enforcement' directive added
+# Polling loop improvements
+#
 # Revision 1.47  2004/05/18 14:49:20  jalet
 # Big code changes to completely remove the need for "requester" directives,
@@ -343,4 +347,17 @@
             return      # No command to launch in the post-hook
             
+    def getPrinterEnforcement(self, printername) :    
+        """Returns if quota enforcement should be strict or laxist for the current printer."""
+        validenforcements = [ "STRICT", "LAXIST" ]     
+        try :
+            enforcement = self.getPrinterOption(printername, "enforcement")
+        except PyKotaConfigError :    
+            return "LAXIST"
+        else :    
+            enforcement = enforcement.upper()
+            if enforcement not in validenforcements :
+                raise PyKotaConfigError, _("Option enforcement in section %s only supports values in %s") % (printername, str(validenforcements))
+            return enforcement    
+            
     def getPrinterPolicy(self, printername) :    
         """Returns the default policy for the current printer."""
Index: /pykota/trunk/pykota/version.py
===================================================================
--- /pykota/trunk/pykota/version.py (revision 1494)
+++ /pykota/trunk/pykota/version.py (revision 1495)
@@ -22,5 +22,5 @@
 #
 
-__version__ = "1.19alpha13_unofficial"
+__version__ = "1.19alpha14_unofficial"
 
 __doc__ = """PyKota : a complete Printing Quota Solution for CUPS and LPRng."""
Index: /pykota/trunk/bin/cupspykota
===================================================================
--- /pykota/trunk/bin/cupspykota (revision 1494)
+++ /pykota/trunk/bin/cupspykota (revision 1495)
@@ -24,4 +24,8 @@
 #
 # $Log$
+# Revision 1.49  2004/05/24 22:45:48  jalet
+# New 'enforcement' directive added
+# Polling loop improvements
+#
 # Revision 1.48  2004/05/24 14:36:24  jalet
 # Revert to old polling loop. Will need optimisations
@@ -252,4 +256,5 @@
             
             # checks the user's quota
+            self.softwareJobPrice = userpquota.computeJobPrice(self.softwareJobSize)
             action = self.warnUserPQuota(userpquota)
             
@@ -311,9 +316,4 @@
         return retcode    
                
-    def setNonBlocking(self, fno) :
-        """Sets a file handle to be non-blocking."""
-        flags = fcntl.fcntl(fno, fcntl.F_GETFL, 0)
-        fcntl.fcntl(fno, fcntl.F_SETFL, flags | os.O_NONBLOCK)
-
     def unregisterFileNo(self, pollobj, fileno) :                
         """Removes a file handle from the polling object."""
@@ -327,25 +327,20 @@
             self.logdebug("File number %s unregistered from polling object." % fileno)
             
-    def formatFileEvent(self, fd, mask, ins, outs) :        
+    def formatFileEvent(self, fd, mask) :        
         """Formats file debug info."""
-        try :
-            name = ins.get(fd, outs.get(fd))["name"]
-        except KeyError :    
-            self.logdebug("File %s not found in %s or %s" % (fd, repr(ins), repr(outs)))
-        else :    
-            maskval = []
-            if mask & select.POLLIN :
-                maskval.append("POLLIN")
-            if mask & select.POLLOUT :
-                maskval.append("POLLOUT")
-            if mask & select.POLLPRI :
-                maskval.append("POLLPRI")
-            if mask & select.POLLERR :
-                maskval.append("POLLERR")
-            if mask & select.POLLHUP :
-                maskval.append("POLLHUP")
-            if mask & select.POLLNVAL :
-                maskval.append("POLLNVAL")
-            return "%s (%s)" % (name, " | ".join(maskval))
+        maskval = []
+        if mask & select.POLLIN :
+            maskval.append("POLLIN")
+        if mask & select.POLLOUT :
+            maskval.append("POLLOUT")
+        if mask & select.POLLPRI :
+            maskval.append("POLLPRI")
+        if mask & select.POLLERR :
+            maskval.append("POLLERR")
+        if mask & select.POLLHUP :
+            maskval.append("POLLHUP")
+        if mask & select.POLLNVAL :
+            maskval.append("POLLNVAL")
+        return "%s (%s)" % (fd, " | ".join(maskval))
         
     def handleData(self) :                    
@@ -378,5 +373,5 @@
         if self.preserveinputfile is None :
             # this is not a real file, we read the job's data
-            # from stdin 
+            # from our temporary file which is a copy of stdin 
             infno = self.jobdatastream.fileno()
             self.jobdatastream.seek(0)
@@ -389,11 +384,12 @@
             endinput = 1
         
-        self.logdebug("Catching SIGTERM.")
+        self.logdebug("Capturing SIGTERM events.")
         signal.signal(signal.SIGTERM, self.sigterm_handler)
         
+        self.logdebug("Entering streams polling loop...")
+        MEGABYTE = 1024*1024
         killed = 0
-        self.logdebug("Entering streams polling loop...")
         status = -1
-        while status == -1 :
+        while (status == -1) and (not killed) and not (inputclosed and outputclosed) :
             # First check if original backend is still alive
             status = subprocess.poll()
@@ -404,76 +400,80 @@
                 try :
                     os.kill(subprocess.pid, signal.SIGTERM)
+                except OSError, msg : # ignore but logs if process was already killed.
+                    self.logdebug("Error while sending signal to pid %s" % subprocess.pid)
+                else :    
                     self.logger.log_message(_("SIGTERM was sent to real backend %s (pid: %s)") % (realbackend, subprocess.pid), "info")
                     killed = 1
-                except : # ignore if process was already killed.
-                    pass
             
             # In any case, deal with any remaining I/O
             availablefds = pollster.poll(5000)
-            for (fd, mask) in availablefds :
-                # self.logdebug("file: %i    mask: %04x" % (fd, mask))
-                try :
-                    if mask & select.POLLOUT :
-                        # We can write
-                        if fd == tocfno :
-                            if indata :
-                                os.write(fd, indata)    
-                                try :
-                                    os.fsync(fd)
-                                except OSError :
-                                    pass
-                                indata = ""
-                            if endinput :    
-                                self.unregisterFileNo(pollster, tocfno)        
-                                self.logdebug("Closing real backend's stdin.")
-                                os.close(tocfno)
-                                inputclosed = 1
-                        elif fd == stderrfno :
-                            if outdata :
-                                os.write(fd, outdata)
-                                try :
-                                    os.fsync(fd)
-                                except OSError :    
-                                    pass
-                                outdata = ""
-                            if endoutput :    
-                                self.unregisterFileNo(pollster, stderrfno)        
-                                outputclosed = 1
-                    if (mask & select.POLLIN) or (mask & select.POLLPRI) :     
-                        # We have something to read
-                        try :
-                            data = os.read(fd, 256 * 1024)
-                        except OSError, msg :    
-                            self.logdebug("Error while reading file %s : %s" % (fd, msg))
-                        else :
+            if not availablefds :
+                self.logdebug("Nothing to do, sleeping a bit...")
+                time.sleep(0.01) # give some time to the system
+            else :
+                for (fd, mask) in availablefds :
+                    # self.logdebug(self.formatFileEvent(fd, mask))
+                    try :
+                        if mask & select.POLLOUT :
+                            # We can write
+                            if fd == tocfno :
+                                if indata :
+                                    try :
+                                        os.write(fd, indata)    
+                                    except IOError, msg :    
+                                        self.logdebug("Error while writing to real backend's stdin %s : %s" % (fd, msg))
+                                    else :    
+                                        indata = ""
+                                if endinput :    
+                                    self.unregisterFileNo(pollster, tocfno)        
+                                    self.logdebug("Closing real backend's stdin.")
+                                    os.close(tocfno)
+                                    inputclosed = 1
+                            elif fd == stderrfno :
+                                if outdata :
+                                    try :
+                                        os.write(fd, outdata)
+                                    except IOError, msg :    
+                                        self.logdebug("Error while writing to CUPS back channel (stderr) %s : %s" % (fd, msg))
+                                    else :
+                                        outdata = ""
+                                if endoutput :    
+                                    self.unregisterFileNo(pollster, stderrfno)        
+                                    outputclosed = 1
+                        if mask & (select.POLLIN | select.POLLPRI) :     
+                            # We have something to read
+                            try :
+                                data = os.read(fd, MEGABYTE)
+                            except (IOError, OSError), msg :    
+                                self.logdebug("Error while reading file %s : %s" % (fd, msg))
+                            else :
+                                if fd == infno :
+                                    indata += data
+                                    if not data :    # If yes, then no more input data
+                                        self.unregisterFileNo(pollster, infno)
+                                        self.logdebug("Input data ends.")
+                                        endinput = 1 # this happens with real files.
+                                elif fd == fromcfno :
+                                    outdata += data
+                        if mask & (select.POLLHUP | select.POLLERR) :
+                            # Treat POLLERR as an EOF.
+                            # Some standard I/O stream has no more datas
+                            self.unregisterFileNo(pollster, fd)
                             if fd == infno :
-                                indata += data
-                                if not data :    # If yes, then no more input data
-                                    self.unregisterFileNo(pollster, infno)
-                                    self.logdebug("Input data ends.")
-                                    endinput = 1 # this happens with real files.
-                            elif fd == fromcfno :
-                                outdata += data
-                    if (mask & select.POLLHUP) or (mask & select.POLLERR) :
-                        # I've never seen POLLERR myself, but this probably
-                        # can't hurt to treat an error condition just like 
-                        # an EOF.
-                        # 
-                        # Some standard I/O stream has no more datas
-                        self.unregisterFileNo(pollster, fd)
-                        if fd == infno :
-                            # Here we are in the case where the input file is stdin.
-                            # which has no more data to be read.
-                            self.logdebug("Input data ends.")
-                            endinput = 1
-                        elif fd == fromcfno :    
-                            # We are no more interested in this file descriptor        
-                            self.logdebug("Closing real backend's stdout+stderr.")
-                            os.close(fromcfno)
-                            endoutput = 1
-                except IOError :            
-                    pass # we got signalled during an I/O it seems
-            if killed or (inputclosed and outputclosed) :
-                break
+                                # Here we are in the case where the input file is stdin.
+                                # which has no more data to be read.
+                                self.logdebug("Input data ends.")
+                                endinput = 1
+                            elif fd == fromcfno :    
+                                # We are no more interested in this file descriptor        
+                                self.logdebug("Closing real backend's stdout+stderr.")
+                                os.close(fromcfno)
+                                endoutput = 1
+                                
+                        if mask & select.POLLNVAL :        
+                            self.logdebug("File %s was closed. Unregistering from polling object." % fd)
+                            self.unregisterFileNo(pollster, fd)
+                    except IOError, msg :            
+                        self.logdebug("Got an IOError : %s" % msg) # we got signalled during an I/O
                 
         # We must close the real backend's input stream
@@ -491,4 +491,5 @@
             # we exited the loop before the real backend exited
             # now we have to wait for it to finish and get its status
+            self.logdebug("Waiting for real backend to exit...")
             try :
                 status = subprocess.wait()
Index: /pykota/trunk/NEWS
===================================================================
--- /pykota/trunk/NEWS (revision 1494)
+++ /pykota/trunk/NEWS (revision 1495)
@@ -22,4 +22,10 @@
 PyKota NEWS :
 
+    - 1.19alpha14 :
+    
+        - New 'enforcement' directive which accepts either
+          STRICT or LAXIST. See sample configuration file
+          for details.
+          
     - 1.19alpha13 :
     
