root / pykota / trunk / bin / cupspykota @ 1188

Revision 1186, 19.5 kB (checked in by jalet, 21 years ago)

General improvements to the documentation.
Email address changed in sample configuration file, because
I receive low quota messages almost every day...

  • 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 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.8  2003/11/15 14:26:44  jalet
27# General improvements to the documentation.
28# Email address changed in sample configuration file, because
29# I receive low quota messages almost every day...
30#
31# Revision 1.7  2003/11/14 22:05:12  jalet
32# New CUPS backend fully functionnal.
33# Old CUPS configuration method is now officially deprecated.
34#
35# Revision 1.6  2003/11/14 20:13:11  jalet
36# We exit the loop too soon.
37#
38# Revision 1.5  2003/11/14 18:31:27  jalet
39# Not perfect, but seems to work with the poll() loop.
40#
41# Revision 1.4  2003/11/14 17:04:15  jalet
42# More (untested) work on the CUPS backend.
43#
44# Revision 1.3  2003/11/12 23:27:44  jalet
45# More work on new backend. This commit may be unstable.
46#
47# Revision 1.2  2003/11/12 09:33:34  jalet
48# New CUPS backend supports device enumeration
49#
50# Revision 1.1  2003/11/08 16:05:31  jalet
51# CUPS backend added for people to experiment.
52#
53#
54#
55
56import sys
57import os
58import popen2
59import time
60import cStringIO
61import shlex
62import select
63import signal
64
65from pykota.tool import PyKotaTool, PyKotaToolError
66from pykota.config import PyKotaConfigError
67from pykota.storage import PyKotaStorageError
68from pykota.accounter import openAccounter, PyKotaAccounterError
69from pykota.requester import openRequester, PyKotaRequesterError
70
71class PyKotaPopen3(popen2.Popen3) :
72    """Our own class to execute real backends.
73   
74       Their first argument is different from their path so using
75       native popen2.Popen3 would not be feasible.
76    """
77    def __init__(self, cmd, capturestderr=False, bufsize=-1, arg0=None) :
78        self.arg0 = arg0
79        popen2.Popen3.__init__(self, cmd, capturestderr, bufsize)
80       
81    def _run_child(self, cmd):
82        for i in range(3, 256): # TODO : MAXFD in original popen2 module
83            try:
84                os.close(i)
85            except OSError:
86                pass
87        try:
88            os.execvpe(cmd[0], [self.arg0 or cmd[0]] + cmd[1:], os.environ)
89        finally:
90            os._exit(1)
91   
92class PyKotaBackend(PyKotaTool) :   
93    """Class for the PyKota backend."""
94    def __init__(self) :
95        PyKotaTool.__init__(self)
96        (self.printingsystem, \
97         self.printerhostname, \
98         self.printername, \
99         self.username, \
100         self.jobid, \
101         self.inputfile, \
102         self.copies, \
103         self.title, \
104         self.options, \
105         self.originalbackend) = self.extractCUPSInfo()
106        self.accounter = openAccounter(self)
107   
108    def extractCUPSInfo(self) :   
109        """Returns a tuple (printingsystem, printerhostname, printername, username, jobid, filename, title, options, backend).
110       
111           Returns (None, None, None, None, None, None, None, None, None, None) if no printing system is recognized.
112        """
113        # Try to detect CUPS
114        if os.environ.has_key("CUPS_SERVERROOT") and os.path.isdir(os.environ.get("CUPS_SERVERROOT", "")) :
115            if len(sys.argv) == 7 :
116                inputfile = sys.argv[6]
117            else :   
118                inputfile = None
119               
120            # the DEVICE_URI environment variable's value is
121            # prefixed with "cupspykota:" otherwise we wouldn't
122            # be called. We have to remove this from the environment
123            # before launching the real backend.
124            fulldevice_uri = os.environ.get("DEVICE_URI", "")
125            device_uri = fulldevice_uri[len("cupspykota:"):]
126            os.environ["DEVICE_URI"] = device_uri
127            # TODO : check this for more complex urls than ipp://myprinter.dot.com:631/printers/lp
128            try :
129                (backend, destination) = device_uri.split(":", 1) 
130            except ValueError :   
131                raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri
132            while destination.startswith("/") :
133                destination = destination[1:]
134            printerhostname = destination.split("/")[0].split(":")[0]
135            return ("CUPS", \
136                    printerhostname, \
137                    os.environ.get("PRINTER"), \
138                    sys.argv[2].strip(), \
139                    sys.argv[1].strip(), \
140                    inputfile, \
141                    int(sys.argv[4].strip()), \
142                    sys.argv[3], \
143                    sys.argv[5], \
144                    backend)
145        else :   
146            self.logger.log_message(_("Printing system unknown, args=%s") % " ".join(sys.argv), "warn")
147            return (None, None, None, None, None, None, None, None, None, None)   # Unknown printing system         
148       
149def format_commandline(prt, usr, cmdline) :           
150    """Passes printer and user names on the command line."""
151    printer = prt.Name
152    user = usr.Name
153    # we don't want the external command's standard
154    # output to break the print job's data, but we
155    # want to keep its standard error
156    return "%s >/dev/null" % (cmdline % locals())
157
158def main(thebackend) :   
159    """Do it, and do it right !"""
160    # first deal with signals
161    # CUPS backends ignore SIGPIPE and exit(1) on SIGTERM
162    # Fortunately SIGPIPE is already ignored by Python
163    # It's there just in case this changes in the future.
164    # But here we will IGNORE SIGTERM for now, and see
165    # in a future release if we shouldn't pass it to the
166    # original backend instead.
167    signal.signal(signal.SIGPIPE, signal.SIG_IGN)
168    signal.signal(signal.SIGTERM, signal.SIG_IGN)
169   
170    #
171    # retrieve some informations on the current printer and user
172    # from the Quota Storage.
173    printer = thebackend.storage.getPrinter(thebackend.printername)
174    if not printer.Exists :
175        # The printer is unknown from the Quota Storage perspective
176        # we let the job pass through, but log a warning message
177        thebackend.logger.log_message(_("Printer %s not registered in the PyKota system") % thebackend.printername, "warn")
178        action = "ALLOW"
179    else :   
180        for dummy in range(2) :
181            user = thebackend.storage.getUser(thebackend.username)
182            if user.Exists :
183                break
184            else :   
185                # The user is unknown from the Quota Storage perspective
186                # Depending on the default policy for this printer, we
187                # either let the job pass through or reject it, but we
188                # log a message in any case.
189                (policy, args) = thebackend.config.getPrinterPolicy(thebackend.printername)
190                if policy == "ALLOW" :
191                    action = "POLICY_ALLOW"
192                elif policy == "EXTERNAL" :   
193                    commandline = format_commandline(printer, user, args)
194                    thebackend.logger.log_message(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (thebackend.username, commandline, thebackend.printername), "info")
195                    if os.system(commandline) :
196                        # if an error occured, we die without error,
197                        # so that the job doesn't stop the print queue.
198                        thebackend.logger.log_message(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, thebackend.printername), "error")
199                        return 0
200                    else :   
201                        # here we try a second time, because the goal
202                        # of the external action was to add the user
203                        # in the database.
204                        continue 
205                else :   
206                    action = "POLICY_DENY"
207                thebackend.logger.log_message(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (thebackend.username, action, thebackend.printername), "warn")
208                if action == "POLICY_DENY" :
209                    # if not allowed to print then die, else proceed.
210                    # we die without error, so that the job doesn't
211                    # stop the print queue.
212                    return 0
213                # when we get there, the printer policy allows the job to pass
214                break   
215                   
216        if user.Exists :
217            # Is the current user allowed to print at all ?
218            action = thebackend.warnUserPQuota(thebackend.storage.getUserPQuota(user, printer))
219        elif policy == "EXTERNAL" :               
220            # if the extenal policy produced no error, but the
221            # user still doesn't exist, we die without error,
222            # so that the job doesn't stop the print queue.
223            thebackend.logger.log_message(_("External policy %s for printer %s couldn't add user %s. Job rejected.") % (commandline, thebackend.printername, thebackend.username), "error")
224            return 0
225       
226    if action not in ["ALLOW", "WARN"] :   
227        # if not allowed to print then die, else proceed.
228        # we die without error, so that the job doesn't
229        # stop the print queue.
230        retcode = 0
231    else :
232        # pass the job untouched to the underlying layer
233        # and starts accounting at the same time
234        thebackend.accounter.beginJob(printer, user)
235       
236        # Now it becomes tricky...
237       
238        # First ensure that we have a file object as input
239        mustclose = 0   
240        if thebackend.inputfile is not None :   
241            if hasattr(thebackend.inputfile, "read") :
242                infile = thebackend.inputfile
243            else :   
244                infile = open(thebackend.inputfile, "rb")
245            mustclose = 1
246        else :   
247            infile = sys.stdin
248           
249        # Find the real backend pathname   
250        realbackend = os.path.join(os.path.split(sys.argv[0])[0], thebackend.originalbackend)
251       
252        # And launch it
253        subprocess = PyKotaPopen3([realbackend] + sys.argv[1:], capturestderr=1, bufsize=0, arg0=os.environ["DEVICE_URI"])
254       
255        # Save file descriptors, we will need them later.
256        infno = infile.fileno()
257        stdoutfno = sys.stdout.fileno()
258        stderrfno = sys.stderr.fileno()
259        fromcfno = subprocess.fromchild.fileno()
260        tocfno = subprocess.tochild.fileno()
261        cerrfno = subprocess.childerr.fileno()
262       
263        # We will have to be careful when dealing with I/O
264        # So we use a poll object to know when to read or write
265        pollster = select.poll()
266        pollster.register(infno, select.POLLIN | select.POLLPRI)
267        pollster.register(fromcfno, select.POLLIN | select.POLLPRI)
268        pollster.register(cerrfno, select.POLLIN | select.POLLPRI)
269        pollster.register(stdoutfno, select.POLLOUT)
270        pollster.register(stderrfno, select.POLLOUT)
271        pollster.register(tocfno, select.POLLOUT)
272       
273        # Initialize our buffers
274        indata = ""
275        outdata = ""
276        errdata = ""
277        endinput = 0
278        status = -1
279        while status == -1 :
280            # First check if original backend is still alive
281            status = subprocess.poll()
282           
283            # In any case, deal with any remaining I/O
284            availablefds = pollster.poll()
285            for (fd, mask) in availablefds :
286                if mask & select.POLLOUT :
287                    # We can write
288                    if fd == tocfno :
289                        if indata :
290                            os.write(fd, indata)   
291                            indata = ""
292                    elif fd == stdoutfno :
293                        if outdata :
294                            os.write(fd, outdata)
295                            outdata = ""
296                    elif fd == stderrfno :
297                        if errdata :
298                            os.write(fd, errdata)
299                            errdata = ""
300                if (mask & select.POLLIN) or (mask & select.POLLPRI) :     
301                    # We have something to read
302                    data = os.read(fd, 256 * 1024)
303                    if fd == infno :
304                        indata += data
305                        if not data :    # If yes, then no more input data
306                            endinput = 1 # this happens with real files.
307                    elif fd == fromcfno :
308                        outdata += data
309                    elif fd == cerrfno :   
310                        errdata += data
311                if mask & select.POLLHUP :
312                    # Some standard I/O stream has no more datas
313                    if fd == infno :
314                        # Here we are in the case where the input file is stdin.
315                        # which has no more data to be read.
316                        endinput = 1
317                    elif fd == fromcfno :   
318                        # This should never happen, since
319                        # CUPS backends don't send anything on their
320                        # standard output
321                        if outdata :               
322                            try :
323                                os.write(stdoutfno, outdata)
324                                outdata = ""
325                            except :   
326                                pass
327                        try :       
328                            pollster.unregister(fromcfno)       
329                        except KeyError :   
330                            pass
331                        else :   
332                            os.close(fromcfno)
333                    elif fd == cerrfno :   
334                        # Original CUPS backend has finished
335                        # to write informations on its standard error
336                        if errdata :               
337                            # Try to write remaining info (normally "Ready to print.")
338                            try :
339                                os.write(stderrfno, errdata)
340                                errdata = ""
341                            except :   
342                                pass
343                        # We are no more interested in this file descriptor       
344                        try :       
345                            pollster.unregister(cerrfno)       
346                        except KeyError :   
347                            pass
348                        else :   
349                            os.close(cerrfno)
350                       
351            if endinput :           
352                # We deal with remaining input datas here
353                # because EOF can happen in two different
354                # situations and I don't want to duplicate
355                # code, nor making functions.
356                if indata :               
357                    try :
358                        os.write(tocfno, indata)
359                        indata = ""
360                    except :   
361                        pass
362                # Again, we're not interested in this file descriptor       
363                # anymore.
364                try :       
365                    pollster.unregister(tocfno)       
366                except KeyError :   
367                    pass
368                else :   
369                    os.close(tocfno)
370           
371        # Input file was a real file, we have to close it.   
372        if mustclose :
373            infile.close()
374           
375        # Check exit code of original CUPS backend.   
376        if os.WIFEXITED(status) :
377            retcode = os.WEXITSTATUS(status)
378        else :   
379            thebackend.logger.log_message(_("CUPS backend %s died abnormally.") % realbackend, "error")
380            retcode = -1
381   
382    # stops accounting.
383    thebackend.accounter.endJob(printer, user)
384       
385    # retrieve the job size   
386    jobsize = thebackend.accounter.getJobSize()
387   
388    # update the quota for the current user on this printer
389    if printer.Exists :
390        if jobsize :
391            userquota = thebackend.storage.getUserPQuota(user, printer)
392            if userquota.Exists :
393                userquota.increasePagesUsage(jobsize)
394       
395        # adds the current job to history   
396        printer.addJobToHistory(thebackend.jobid, user, thebackend.accounter.getLastPageCounter(), action, jobsize)
397   
398    return retcode
399
400if __name__ == "__main__" :   
401    # This is a CUPS backend, we should act and die like a CUPS backend
402    if len(sys.argv) == 1 :
403        # we will execute each existing backend in device enumeration mode
404        # and generate their PyKota accounting counterpart
405        (directory, myname) = os.path.split(sys.argv[0])
406        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)] :
407            answer = os.popen(backend, "r")
408            try :
409                devices = [line.strip() for line in answer.readlines()]
410            except :   
411                devices = []
412            status = answer.close()
413            if status is None :
414                for d in devices :
415                    # each line is of the form : 'xxxx xxxx "xxxx xxx" "xxxx xxx"'
416                    # so we have to decompose it carefully
417                    fdevice = cStringIO.StringIO("%s" % d)
418                    tokenizer = shlex.shlex(fdevice)
419                    tokenizer.wordchars = tokenizer.wordchars + r".:,?!~/\_$*-+={}[]()#"
420                    arguments = []
421                    while 1 :
422                        token = tokenizer.get_token()
423                        if token :
424                            arguments.append(token)
425                        else :
426                            break
427                    fdevice.close()
428                    try :
429                        (devicetype, device, name, fullname) = arguments
430                    except ValueError :   
431                        pass    # ignore this 'bizarre' device
432                    else :   
433                        if name.startswith('"') and name.endswith('"') :
434                            name = name[1:-1]
435                        if fullname.startswith('"') and fullname.endswith('"') :
436                            fullname = fullname[1:-1]
437                        print '%s cupspykota:%s "PyKota+%s" "PyKota managed %s"' % (devicetype, device, name, fullname)   
438        retcode = 0
439    elif len(sys.argv) not in (6, 7) :   
440        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n" % sys.argv[0])
441        retcode = 1
442    else :   
443        try :
444            # Initializes the backend
445            kotabackend = PyKotaBackend()   
446            retcode = main(kotabackend)
447        except (PyKotaToolError, PyKotaConfigError, PyKotaStorageError, PyKotaAccounterError, AttributeError, KeyError, IndexError, ValueError, TypeError, IOError), msg :
448            sys.stderr.write("ERROR : cupspykota backend failed (%s)\n" % msg)
449            sys.stderr.flush()
450            retcode = 1
451       
452        try :
453            kotabackend.storage.close()
454        except (TypeError, NameError, AttributeError) :   
455            pass
456       
457    sys.exit(retcode)   
Note: See TracBrowser for help on using the browser.