root / pykota / trunk / bin / cupspykota @ 1471

Revision 1467, 22.2 kB (checked in by jalet, 21 years ago)

Typo

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