root / pykota / trunk / bin / cupspykota @ 1478

Revision 1478, 21.7 kB (checked in by jalet, 20 years ago)

First try at cupspykota's main loop rewrite

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