root / pykota / trunk / bin / cupspykota @ 1400

Revision 1400, 20.3 kB (checked in by jalet, 20 years ago)

This time the traceback formatting should be correct !

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