root / pykota / trunk / bin / cupspykota @ 1493

Revision 1493, 22.8 kB (checked in by jalet, 20 years ago)

More robust (?) code

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