root / pykota / trunk / bin / cupspykota @ 1372

Revision 1372, 19.4 kB (checked in by jalet, 20 years ago)

Pre and Post hooks to external commands are available in the cupspykota
backend. Forthe pykota filter they will be implemented real soon now.

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