root / pykota / trunk / bin / cupspykota @ 1405

Revision 1405, 20.5 kB (checked in by jalet, 20 years ago)

Small fix for new waitprinter.sh : when job was denied, would wait forever
for printer being in printing mode.

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