root / pykota / trunk / bin / cupspykota @ 1466

Revision 1466, 22.2 kB (checked in by jalet, 20 years ago)

Catches OSError while reading

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