root / pykota / trunk / bin / cupspykota @ 1374

Revision 1374, 19.7 kB (checked in by jalet, 20 years ago)

PYKOTAPHASE wasn't set at the right time at the end of data transmission
to underlying layer (real backend)

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