root / pykota / trunk / bin / cupspykota @ 1271

Revision 1271, 17.0 kB (checked in by jalet, 20 years ago)

Major code refactoring, it's way cleaner, and now allows automated addition
of printers on first print.

  • Property svn:eol-style set to native
  • Property svn:executable set to *
  • Property svn:keywords set to Author Date Id Revision
Line 
1#! /usr/bin/env python
2# -*- coding: ISO-8859-15 -*-
3
4# CUPSPyKota accounting backend
5#
6# PyKota - Print Quotas for CUPS and LPRng
7#
8# (c) 2003-2004 Jerome Alet <alet@librelogiciel.com>
9# This program is free software; you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation; either version 2 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program; if not, write to the Free Software
21# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
22#
23# $Id$
24#
25# $Log$
26# Revision 1.20  2004/01/11 23:22:42  jalet
27# Major code refactoring, it's way cleaner, and now allows automated addition
28# of printers on first print.
29#
30# Revision 1.19  2004/01/08 14:10:32  jalet
31# Copyright year changed.
32#
33# Revision 1.18  2004/01/07 16:16:32  jalet
34# Better debugging information
35#
36# Revision 1.17  2003/12/27 16:49:25  uid67467
37# Should be ok now.
38#
39# Revision 1.17  2003/12/06 08:54:29  jalet
40# Code simplifications.
41# Added many debugging messages.
42#
43# Revision 1.16  2003/11/26 20:43:29  jalet
44# Inadvertantly introduced a bug, which is fixed.
45#
46# Revision 1.15  2003/11/26 19:17:35  jalet
47# Printing on a printer not present in the Quota Storage now results
48# in the job being stopped or cancelled depending on the system.
49#
50# Revision 1.14  2003/11/25 13:25:45  jalet
51# Boolean problem with old Python, replaced with 0
52#
53# Revision 1.13  2003/11/23 19:01:35  jalet
54# Job price added to history
55#
56# Revision 1.12  2003/11/21 14:28:43  jalet
57# More complete job history.
58#
59# Revision 1.11  2003/11/19 23:19:35  jalet
60# Code refactoring work.
61# Explicit redirection to /dev/null has to be set in external policy now, just
62# like in external mailto.
63#
64# Revision 1.10  2003/11/18 17:54:24  jalet
65# SIGTERMs are now transmitted to original backends.
66#
67# Revision 1.9  2003/11/18 14:11:07  jalet
68# Small fix for bizarre urls
69#
70# Revision 1.8  2003/11/15 14:26:44  jalet
71# General improvements to the documentation.
72# Email address changed in sample configuration file, because
73# I receive low quota messages almost every day...
74#
75# Revision 1.7  2003/11/14 22:05:12  jalet
76# New CUPS backend fully functionnal.
77# Old CUPS configuration method is now officially deprecated.
78#
79# Revision 1.6  2003/11/14 20:13:11  jalet
80# We exit the loop too soon.
81#
82# Revision 1.5  2003/11/14 18:31:27  jalet
83# Not perfect, but seems to work with the poll() loop.
84#
85# Revision 1.4  2003/11/14 17:04:15  jalet
86# More (untested) work on the CUPS backend.
87#
88# Revision 1.3  2003/11/12 23:27:44  jalet
89# More work on new backend. This commit may be unstable.
90#
91# Revision 1.2  2003/11/12 09:33:34  jalet
92# New CUPS backend supports device enumeration
93#
94# Revision 1.1  2003/11/08 16:05:31  jalet
95# CUPS backend added for people to experiment.
96#
97#
98#
99
100import sys
101import os
102import popen2
103import cStringIO
104import shlex
105import select
106import signal
107
108from pykota.tool import PyKotaFilterOrBackend, PyKotaToolError
109from pykota.config import PyKotaConfigError
110from pykota.storage import PyKotaStorageError
111from pykota.accounter import PyKotaAccounterError
112from pykota.requester import PyKotaRequesterError
113
114gotSigTerm = 0
115def sigterm_handler(signum, frame) :
116    """Sets a global variable whenever SIGTERM is received."""
117    # SIGTERM will be ignore most of the time, but during
118    # the call to the real backend, we have to pass it through.
119    global gotSigTerm
120    gotSigTerm = 1
121   
122class PyKotaPopen3(popen2.Popen3) :
123    """Our own class to execute real backends.
124   
125       Their first argument is different from their path so using
126       native popen2.Popen3 would not be feasible.
127    """
128    def __init__(self, cmd, capturestderr=0, bufsize=-1, arg0=None) :
129        self.arg0 = arg0
130        popen2.Popen3.__init__(self, cmd, capturestderr, bufsize)
131       
132    def _run_child(self, cmd):
133        for i in range(3, 256): # TODO : MAXFD in original popen2 module
134            try:
135                os.close(i)
136            except OSError:
137                pass
138        try:
139            os.execvpe(cmd[0], [self.arg0 or cmd[0]] + cmd[1:], os.environ)
140        finally:
141            os._exit(1)
142   
143class PyKotaBackend(PyKotaFilterOrBackend) :       
144    """A class for the pykota backend."""
145    def acceptJob(self) :       
146        """Returns the appropriate exit code to tell CUPS all is OK."""
147        return 0
148           
149    def removeJob(self) :           
150        """Returns the appropriate exit code to let CUPS think all is OK.
151       
152           Returning 0 (success) prevents CUPS from stopping the print queue.
153        """   
154        return 0
155       
156    def doWork(self, policy, printer, user, userpquota) :   
157        """Most of the work is done here."""
158        global gotSigTerm
159        # Two different values possible for policy here :
160        # ALLOW means : Either printer, user or user print quota doesn't exist,
161        #               but the job should be allowed anyway.
162        # OK means : Both printer, user and user print quota exist, job should
163        #            be allowed if current user is allowed to print on this printer
164        if policy == "OK" :
165            self.logdebug("Checking user %s's quota on printer %s" % (user.Name, printer.Name))
166            action = self.warnUserPQuota(userpquota)
167            if action not in ["ALLOW", "WARN"] :   
168                return self.removeJob()
169            self.logdebug("Job accounting begins.")
170            self.accounter.beginJob(userpquota)
171           
172        # pass the job's data to the real backend   
173        if gotSigTerm :
174            retcode = self.removeJob()
175        else :   
176            retcode = self.handleData()       
177       
178        if policy == "OK" :       
179            # stops accounting.
180            self.accounter.endJob(userpquota)
181            self.logdebug("Job accounting ends.")
182               
183            # retrieve the job size   
184            jobsize = self.accounter.getJobSize()
185            self.logdebug("Job size : %i" % jobsize)
186           
187            # update the quota for the current user on this printer
188            jobprice = (float(printer.PricePerPage or 0.0) * jobsize) + float(printer.PricePerJob or 0.0)
189            if jobsize :
190                self.logdebug("Updating user %s's quota on printer %s" % (user.Name, printer.Name))
191                userpquota.increasePagesUsage(jobsize)
192           
193            # adds the current job to history   
194            printer.addJobToHistory(self.jobid, user, self.accounter.getLastPageCounter(), action, jobsize, jobprice, self.preserveinputfile, self.title, self.copies, self.options)
195            self.logdebug("Job added to history.")
196           
197        return retcode   
198                   
199    def handleData(self) :                   
200        """Pass the job's data to the real backend."""
201        # Now it becomes tricky...
202        # We must pass the unmodified job to the original backend
203       
204        global gotSigTerm
205       
206        # First ensure that we have a file object as input
207        mustclose = 0   
208        if self.inputfile is not None :   
209            if hasattr(self.inputfile, "read") :
210                infile = self.inputfile
211            else :   
212                infile = open(self.inputfile, "rb")
213            mustclose = 1
214        else :   
215            infile = sys.stdin
216           
217        # Find the real backend pathname   
218        realbackend = os.path.join(os.path.split(sys.argv[0])[0], self.originalbackend)
219       
220        # And launch it
221        self.logdebug("Starting real backend %s with args %s" % (realbackend, " ".join(['"%s"' % a for a in ([os.environ["DEVICE_URI"]] + sys.argv[1:])])))
222        subprocess = PyKotaPopen3([realbackend] + sys.argv[1:], capturestderr=1, bufsize=0, arg0=os.environ["DEVICE_URI"])
223       
224        # Save file descriptors, we will need them later.
225        infno = infile.fileno()
226        stdoutfno = sys.stdout.fileno()
227        stderrfno = sys.stderr.fileno()
228        fromcfno = subprocess.fromchild.fileno()
229        tocfno = subprocess.tochild.fileno()
230        cerrfno = subprocess.childerr.fileno()
231       
232        # We will have to be careful when dealing with I/O
233        # So we use a poll object to know when to read or write
234        pollster = select.poll()
235        pollster.register(infno, select.POLLIN | select.POLLPRI)
236        pollster.register(fromcfno, select.POLLIN | select.POLLPRI)
237        pollster.register(cerrfno, select.POLLIN | select.POLLPRI)
238        pollster.register(stdoutfno, select.POLLOUT)
239        pollster.register(stderrfno, select.POLLOUT)
240        pollster.register(tocfno, select.POLLOUT)
241       
242        # Initialize our buffers
243        indata = ""
244        outdata = ""
245        errdata = ""
246        endinput = 0
247        status = -1
248        inputclosed = 0
249        self.logdebug("Entering streams polling loop...")
250        while status == -1 :
251            # First check if original backend is still alive
252            status = subprocess.poll()
253           
254            # Now if we got SIGTERM, we have
255            # to kill -TERM the original backend
256            if gotSigTerm :
257                try :
258                    os.kill(subprocess.pid, signal.SIGTERM)
259                except : # ignore if process was already killed.
260                    pass
261           
262            # In any case, deal with any remaining I/O
263            availablefds = pollster.poll()
264            for (fd, mask) in availablefds :
265                # self.logdebug("file: %i    mask: %04x" % (fd, mask))
266                if mask & select.POLLOUT :
267                    # We can write
268                    if fd == tocfno :
269                        if indata :
270                            os.write(fd, indata)   
271                            indata = ""
272                    elif fd == stdoutfno :
273                        if outdata :
274                            os.write(fd, outdata)
275                            outdata = ""
276                    elif fd == stderrfno :
277                        if errdata :
278                            os.write(fd, errdata)
279                            errdata = ""
280                if (mask & select.POLLIN) or (mask & select.POLLPRI) :     
281                    # We have something to read
282                    data = os.read(fd, 256 * 1024)
283                    if fd == infno :
284                        indata += data
285                        if not data :    # If yes, then no more input data
286                            endinput = 1 # this happens with real files.
287                    elif fd == fromcfno :
288                        outdata += data
289                    elif fd == cerrfno :   
290                        errdata += data
291                if (mask & select.POLLHUP) or (mask & select.POLLERR) :
292                    # I've never seen POLLERR myself, but this probably
293                    # can't hurt to treat an error condition just like
294                    # an EOF.
295                    #
296                    # Some standard I/O stream has no more datas
297                    if fd == infno :
298                        # Here we are in the case where the input file is stdin.
299                        # which has no more data to be read.
300                        endinput = 1
301                    elif fd == fromcfno :   
302                        # This should never happen, since
303                        # CUPS backends don't send anything on their
304                        # standard output
305                        if outdata :               
306                            os.write(stdoutfno, outdata)
307                            outdata = ""
308                        # We are no more interested in this file descriptor       
309                        pollster.unregister(fromcfno)       
310                        os.close(fromcfno)
311                        self.logdebug("Real backend's stdout ends.")
312                    elif fd == cerrfno :   
313                        # Original CUPS backend has finished
314                        # to write informations on its standard error
315                        if errdata :               
316                            # Try to write remaining info (normally "Ready to print.")
317                            os.write(stderrfno, errdata)
318                            errdata = ""
319                        # We are no more interested in this file descriptor       
320                        pollster.unregister(cerrfno)       
321                        os.close(cerrfno)
322                        self.logdebug("Real backend's stderr ends.")
323                       
324            if endinput and not inputclosed :           
325                # We deal with remaining input datas here
326                # because EOF can happen in two different
327                # situations and I don't want to duplicate
328                # code, nor making functions.
329                if indata :               
330                    os.write(tocfno, indata)
331                    indata = ""
332                # Again, we're not interested in this file descriptor anymore.
333                pollster.unregister(tocfno)       
334                os.close(tocfno)
335                inputclosed = 1
336                self.logdebug("Input data ends.")
337               
338        # Input file was a real file, we have to close it.   
339        if mustclose :
340            infile.close()
341           
342        self.logdebug("Exiting streams polling loop...")
343           
344        # Check exit code of original CUPS backend.   
345        if os.WIFEXITED(status) :
346            retcode = os.WEXITSTATUS(status)
347        else :   
348            self.logger.log_message(_("CUPS backend %s died abnormally.") % realbackend, "error")
349            retcode = -1
350        return retcode   
351   
352if __name__ == "__main__" :   
353    # This is a CUPS backend, we should act and die like a CUPS backend
354   
355    # first deal with signals
356    # CUPS backends ignore SIGPIPE and exit(1) on SIGTERM
357    # Fortunately SIGPIPE is already ignored by Python
358    # It's there just in case this changes in the future.
359    # Here we have to handle SIGTERM correctly, and pass
360    # it to the original backend if needed.
361    global gotSigTerm
362    gotSigTerm = 0
363    signal.signal(signal.SIGPIPE, signal.SIG_IGN)
364    signal.signal(signal.SIGTERM, sigterm_handler)
365   
366    if len(sys.argv) == 1 :
367        # we will execute each existing backend in device enumeration mode
368        # and generate their PyKota accounting counterpart
369        (directory, myname) = os.path.split(sys.argv[0])
370        for backend in [os.path.join(directory, b) for b in os.listdir(directory) if os.path.isfile(os.path.join(directory, b)) and (b != myname)] :
371            answer = os.popen(backend, "r")
372            try :
373                devices = [line.strip() for line in answer.readlines()]
374            except :   
375                devices = []
376            status = answer.close()
377            if status is None :
378                for d in devices :
379                    # each line is of the form : 'xxxx xxxx "xxxx xxx" "xxxx xxx"'
380                    # so we have to decompose it carefully
381                    fdevice = cStringIO.StringIO("%s" % d)
382                    tokenizer = shlex.shlex(fdevice)
383                    tokenizer.wordchars = tokenizer.wordchars + r".:,?!~/\_$*-+={}[]()#"
384                    arguments = []
385                    while 1 :
386                        token = tokenizer.get_token()
387                        if token :
388                            arguments.append(token)
389                        else :
390                            break
391                    fdevice.close()
392                    try :
393                        (devicetype, device, name, fullname) = arguments
394                    except ValueError :   
395                        pass    # ignore this 'bizarre' device
396                    else :   
397                        if name.startswith('"') and name.endswith('"') :
398                            name = name[1:-1]
399                        if fullname.startswith('"') and fullname.endswith('"') :
400                            fullname = fullname[1:-1]
401                        print '%s cupspykota:%s "PyKota+%s" "PyKota managed %s"' % (devicetype, device, name, fullname)
402        retcode = 0
403    elif len(sys.argv) not in (6, 7) :   
404        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n" % sys.argv[0])
405        retcode = 1
406    else :   
407        try :
408            # Initializes the backend
409            kotabackend = PyKotaBackend()   
410            retcode = kotabackend.mainWork()
411        except (PyKotaToolError, PyKotaConfigError, PyKotaStorageError, PyKotaAccounterError, PyKotaRequesterError, AttributeError, KeyError, IndexError, ValueError, TypeError, IOError), msg :
412            sys.stderr.write("ERROR : cupspykota backend failed (%s)\n" % msg)
413            sys.stderr.flush()
414            retcode = 1
415       
416        try :
417            kotabackend.storage.close()
418        except (TypeError, NameError, AttributeError) :   
419            pass
420       
421    sys.exit(retcode)   
Note: See TracBrowser for help on using the browser.