root / pykota / trunk / bin / cupspykota @ 1484

Revision 1484, 22.0 kB (checked in by jalet, 20 years ago)

Could the 'misterious' bug in my loop be finally fixed ???

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