root / pykota / trunk / bin / cupspykota @ 1291

Revision 1291, 17.9 kB (checked in by jalet, 20 years ago)

Small fix for job cancelling code.

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