root / pykota / trunk / bin / cupspykota @ 1458

Revision 1458, 21.3 kB (checked in by jalet, 20 years ago)

Fix for file handles unregistered twice from the polling object

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