root / pykota / trunk / bin / cupspykota @ 1494

Revision 1494, 22.3 kB (checked in by jalet, 20 years ago)

Revert to old polling loop. Will need optimisations

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