root / pykota / trunk / bin / cupspykota @ 1391

Revision 1391, 20.1 kB (checked in by jalet, 20 years ago)

Improve tracebacks

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