root / pykota / trunk / bin / cupspykota @ 1258

Revision 1257, 19.2 kB (checked in by jalet, 21 years ago)

Copyright year changed.

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