root / pykota / trunk / bin / cupspykota @ 1483

Revision 1483, 21.8 kB (checked in by jalet, 20 years ago)

Big code changes to completely remove the need for "requester" directives,
jsut use "hardware(... your previous requester directive's content ...)"

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