root / pykota / trunk / bin / cupspykota @ 1365

Revision 1365, 18.5 kB (checked in by jalet, 20 years ago)

Should fix the remaining bugs wrt printers groups and users groups.

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