root / tea4cups / trunk / tea4cups @ 585

Revision 585, 24.0 kB (checked in by jerome, 14 years ago)

Added generic exception handling routine

  • Property svn:executable set to *
  • Property svn:keywords set to Author Date Id Rev
Line 
1#! /usr/bin/env python
2# -*- coding: ISO-8859-15 -*-
3
4# Tea4CUPS : Tee for CUPS
5#
6# (c) 2005 Jerome Alet <alet@librelogiciel.com>
7# This program is free software; you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation; either version 2 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program; if not, write to the Free Software
19# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
20#
21# $Id$
22#
23#
24
25import sys
26import os
27import popen2
28import errno
29import md5
30import cStringIO
31import shlex
32import tempfile
33import ConfigParser
34from struct import unpack
35
36version = "0.99"
37
38class TeeError(Exception):
39    """Base exception for Tea4CUPS related stuff."""
40    def __init__(self, message = ""):
41        self.message = message
42        Exception.__init__(self, message)
43    def __repr__(self):
44        return self.message
45    __str__ = __repr__
46   
47class ConfigError(TeeError) :   
48    """Configuration related exceptions."""
49    pass 
50   
51class IPPError(TeeError) :   
52    """IPP related exceptions."""
53    pass 
54   
55class Popen4ForCUPS(popen2.Popen4) :
56    """Our own class to execute real backends.
57   
58       Their first argument is different from their path so using
59       native popen2.Popen3 would not be feasible.
60    """
61    def __init__(self, cmd, bufsize=-1, arg0=None) :
62        self.arg0 = arg0
63        popen2.Popen4.__init__(self, cmd, bufsize)
64       
65    def _run_child(self, cmd):
66        try :
67            MAXFD = os.sysconf("SC_OPEN_MAX")
68        except (AttributeError, ValueError) :   
69            MAXFD = 256
70        for i in range(3, MAXFD) : 
71            try:
72                os.close(i)
73            except OSError:
74                pass
75        try:
76            os.execvpe(cmd[0], [self.arg0 or cmd[0]] + cmd[1:], os.environ)
77        finally:
78            os._exit(1)
79   
80# Some IPP constants   
81OPERATION_ATTRIBUTES_TAG = 0x01
82JOB_ATTRIBUTES_TAG = 0x02
83END_OF_ATTRIBUTES_TAG = 0x03
84PRINTER_ATTRIBUTES_TAG = 0x04
85UNSUPPORTED_ATTRIBUTES_TAG = 0x05
86
87class IPPMessage :
88    """A class for IPP message files."""
89    def __init__(self, data) :
90        """Initializes an IPP Message object."""
91        self.data = data
92        self._attributes = {}
93        self.curname = None
94        self.tags = [ None ] * 256      # by default all tags reserved
95       
96        # Delimiter tags
97        self.tags[0x01] = "operation-attributes-tag"
98        self.tags[0x02] = "job-attributes-tag"
99        self.tags[0x03] = "end-of-attributes-tag"
100        self.tags[0x04] = "printer-attributes-tag"
101        self.tags[0x05] = "unsupported-attributes-tag"
102       
103        # out of band values
104        self.tags[0x10] = "unsupported"
105        self.tags[0x11] = "reserved-for-future-default"
106        self.tags[0x12] = "unknown"
107        self.tags[0x13] = "no-value"
108       
109        # integer values
110        self.tags[0x20] = "generic-integer"
111        self.tags[0x21] = "integer"
112        self.tags[0x22] = "boolean"
113        self.tags[0x23] = "enum"
114       
115        # octetString
116        self.tags[0x30] = "octetString-with-an-unspecified-format"
117        self.tags[0x31] = "dateTime"
118        self.tags[0x32] = "resolution"
119        self.tags[0x33] = "rangeOfInteger"
120        self.tags[0x34] = "reserved-for-collection"
121        self.tags[0x35] = "textWithLanguage"
122        self.tags[0x36] = "nameWithLanguage"
123       
124        # character strings
125        self.tags[0x20] = "generic-character-string"
126        self.tags[0x41] = "textWithoutLanguage"
127        self.tags[0x42] = "nameWithoutLanguage"
128        # self.tags[0x43] = "reserved"
129        self.tags[0x44] = "keyword"
130        self.tags[0x45] = "uri"
131        self.tags[0x46] = "uriScheme"
132        self.tags[0x47] = "charset"
133        self.tags[0x48] = "naturalLanguage"
134        self.tags[0x49] = "mimeMediaType"
135       
136        # now parses the IPP message
137        self.parse()
138       
139    def __getattr__(self, attrname) :   
140        """Allows self.attributes to return the attributes names."""
141        if attrname == "attributes" :
142            keys = self._attributes.keys()
143            keys.sort()
144            return keys
145        raise AttributeError, attrname
146           
147    def __getitem__(self, ippattrname) :   
148        """Fakes a dictionnary d['key'] notation."""
149        value = self._attributes.get(ippattrname)
150        if value is not None :
151            if len(value) == 1 :
152                value = value[0]
153        return value       
154    get = __getitem__   
155       
156    def parseTag(self) :   
157        """Extracts information from an IPP tag."""
158        pos = self.position
159        valuetag = self.tags[ord(self.data[pos])]
160        # print valuetag.get("name")
161        pos += 1
162        posend = pos2 = pos + 2
163        namelength = unpack(">H", self.data[pos:pos2])[0]
164        if not namelength :
165            name = self.curname
166        else :   
167            posend += namelength
168            self.curname = name = self.data[pos2:posend]
169        pos2 = posend + 2
170        valuelength = unpack(">H", self.data[posend:pos2])[0]
171        posend = pos2 + valuelength
172        value = self.data[pos2:posend]
173        oldval = self._attributes.setdefault(name, [])
174        oldval.append(value)
175        return posend - self.position
176       
177    def operation_attributes_tag(self) : 
178        """Indicates that the parser enters into an operation-attributes-tag group."""
179        return self.parseTag()
180       
181    def job_attributes_tag(self) : 
182        """Indicates that the parser enters into an operation-attributes-tag group."""
183        return self.parseTag()
184       
185    def printer_attributes_tag(self) : 
186        """Indicates that the parser enters into an operation-attributes-tag group."""
187        return self.parseTag()
188       
189    def parse(self) :
190        """Parses an IPP Message.
191       
192           NB : Only a subset of RFC2910 is implemented.
193           We are only interested in textual informations for now anyway.
194        """
195        self.version = "%s.%s" % (ord(self.data[0]), ord(self.data[1]))
196        self.operation_id = "0x%04x" % unpack(">H", self.data[2:4])[0]
197        self.request_id = "0x%08x" % unpack(">I", self.data[4:8])[0]
198        self.position = 8
199        try :
200            tag = ord(self.data[self.position])
201            while tag != END_OF_ATTRIBUTES_TAG :
202                self.position += 1
203                name = self.tags[tag]
204                if name is not None :
205                    func = getattr(self, name.replace("-", "_"), None)
206                    if func is not None :
207                        self.position += func()
208                        if ord(self.data[self.position]) > UNSUPPORTED_ATTRIBUTES_TAG :
209                            self.position -= 1
210                            continue
211                tag = ord(self.data[self.position])
212        except IndexError :
213            raise IPPError, "Unexpected end of IPP message."
214           
215class FakeConfig :   
216    """Fakes a configuration file parser."""
217    def get(self, section, option, raw=0) :
218        """Fakes the retrieval of a global option."""
219        raise ConfigError, "Invalid configuration file : no option %s in section [%s]" % (option, section)
220       
221class CupsBackend :
222    """Base class for tools with no database access."""
223    def __init__(self) :
224        """Initializes the CUPS backend wrapper."""
225        self.MyName = "Tea4CUPS"
226        self.myname = "tea4cups"
227        self.pid = os.getpid()
228        confdir = os.environ.get("CUPS_SERVERROOT", ".") 
229        self.conffile = os.path.join(confdir, "%s.conf" % self.myname)
230        if os.path.isfile(self.conffile) :
231            self.config = ConfigParser.ConfigParser()
232            self.config.read([self.conffile])
233            self.debug = self.isTrue(self.getGlobalOption("debug", ignore=1))
234        else :   
235            self.config = FakeConfig()
236            self.debug = 1      # no config, so force debug mode !
237           
238    def logInfo(self, message, level="info") :       
239        """Logs a message to CUPS' error_log file."""
240        sys.stderr.write("%s: %s v%s (PID %i) : %s\n" % (level.upper(), self.MyName, version, os.getpid(), message))
241        sys.stderr.flush()
242       
243    def logDebug(self, message) :   
244        """Logs something to debug output if debug is enabled."""
245        if self.debug :
246            self.logInfo(message, level="debug")
247       
248    def isTrue(self, option) :       
249        """Returns 1 if option is set to true, else 0."""
250        if (option is not None) and (option.upper().strip() in ['Y', 'YES', '1', 'ON', 'T', 'TRUE']) :
251            return 1
252        else :   
253            return 0
254                       
255    def getGlobalOption(self, option, ignore=0) :   
256        """Returns an option from the global section, or raises a ConfigError if ignore is not set, else returns None."""
257        try :
258            return self.config.get("global", option, raw=1)
259        except (ConfigParser.NoSectionError, ConfigParser.NoOptionError) :   
260            if not ignore :
261                raise ConfigError, "Option %s not found in section global of %s" % (option, self.conffile)
262               
263    def getPrintQueueOption(self, printqueuename, option, ignore=0) :   
264        """Returns an option from the printer section, or the global section, or raises a ConfigError."""
265        globaloption = self.getGlobalOption(option, ignore=1)
266        try :
267            return self.config.get(printqueuename, option, raw=1)
268        except (ConfigParser.NoSectionError, ConfigParser.NoOptionError) :   
269            if globaloption is not None :
270                return globaloption
271            elif not ignore :
272                raise ConfigError, "Option %s not found in section [%s] of %s" % (option, printqueuename, self.conffile)
273               
274    def enumTeeBranches(self, printqueuename) :
275        """Returns the list of branches for a particular section's Tee."""
276        try :
277            globalbranches = [ (k, v) for (k, v) in self.config.items("global") if k.startswith("tee_") ]
278        except ConfigParser.NoSectionError, msg :   
279            raise ConfigError, "Invalid configuration file : %s" % msg
280        try :
281            sectionbranches = [ (k, v) for (k, v) in self.config.items(printqueuename) if k.startswith("tee_") ]
282        except ConfigParser.NoSectionError, msg :   
283            self.logInfo("No section for print queue %s : %s" % (printqueuename, msg))
284            sectionbranches = []
285        branches = {}
286        for (k, v) in globalbranches :
287            value = v.strip()
288            if value :
289                branches[k] = value
290        for (k, v) in sectionbranches :   
291            value = v.strip()
292            if value :
293                branches[k] = value # overwrite any global option or set a new value
294            else :   
295                del branches[k] # empty value disables a global option
296        return branches
297       
298    def discoverOtherBackends(self) :   
299        """Discovers the other CUPS backends.
300       
301           Executes each existing backend in turn in device enumeration mode.
302           Returns the list of available backends.
303        """
304        # Unfortunately this method can't output any debug information
305        # to stdout or stderr, else CUPS considers that the device is
306        # not available.
307        available = []
308        (directory, myname) = os.path.split(sys.argv[0])
309        tmpdir = tempfile.gettempdir()
310        lockfilename = os.path.join(tmpdir, "%s..LCK" % myname)
311        if os.path.exists(lockfilename) :
312            lockfile = open(lockfilename, "r")
313            pid = int(lockfile.read())
314            lockfile.close()
315            try :
316                # see if the pid contained in the lock file is still running
317                os.kill(pid, 0)
318            except OSError, e :   
319                if e.errno != errno.EPERM :
320                    # process doesn't exist anymore
321                    os.remove(lockfilename)
322           
323        if not os.path.exists(lockfilename) :
324            lockfile = open(lockfilename, "w")
325            lockfile.write("%i" % self.pid)
326            lockfile.close()
327            allbackends = [ os.path.join(directory, b) \
328                                for b in os.listdir(directory) 
329                                    if os.access(os.path.join(directory, b), os.X_OK) \
330                                        and (b != myname)] 
331            for backend in allbackends :                           
332                answer = os.popen(backend, "r")
333                try :
334                    devices = [line.strip() for line in answer.readlines()]
335                except :   
336                    devices = []
337                status = answer.close()
338                if status is None :
339                    for d in devices :
340                        # each line is of the form :
341                        # 'xxxx xxxx "xxxx xxx" "xxxx xxx"'
342                        # so we have to decompose it carefully
343                        fdevice = cStringIO.StringIO(d)
344                        tokenizer = shlex.shlex(fdevice)
345                        tokenizer.wordchars = tokenizer.wordchars + \
346                                                        r".:,?!~/\_$*-+={}[]()#"
347                        arguments = []
348                        while 1 :
349                            token = tokenizer.get_token()
350                            if token :
351                                arguments.append(token)
352                            else :
353                                break
354                        fdevice.close()
355                        try :
356                            (devicetype, device, name, fullname) = arguments
357                        except ValueError :   
358                            pass    # ignore this 'bizarre' device
359                        else :   
360                            if name.startswith('"') and name.endswith('"') :
361                                name = name[1:-1]
362                            if fullname.startswith('"') and fullname.endswith('"') :
363                                fullname = fullname[1:-1]
364                            available.append('%s %s:%s "%s+%s" "%s managed %s"' \
365                                                 % (devicetype, self.myname, device, self.MyName, name, self.MyName, fullname))
366            os.remove(lockfilename)
367        return available
368                       
369    def initBackend(self) :   
370        """Initializes the backend's attributes."""
371        # check that the DEVICE_URI environment variable's value is
372        # prefixed with self.myname otherwise don't touch it.
373        # If this is the case, we have to remove the prefix from
374        # the environment before launching the real backend
375        muststartwith = "%s:" % self.myname
376        device_uri = os.environ.get("DEVICE_URI", "")
377        if device_uri.startswith(muststartwith) :
378            fulldevice_uri = device_uri[:]
379            device_uri = fulldevice_uri[len(muststartwith):]
380            for i in range(2) :
381                if device_uri.startswith("/") : 
382                    device_uri = device_uri[1:]
383        try :
384            (backend, destination) = device_uri.split(":", 1) 
385        except ValueError :   
386            if not device_uri :
387                self.logInfo("Not attached to an existing print queue.")
388                backend = ""
389            else :   
390                raise TeeError, "Invalid DEVICE_URI : %s\n" % device_uri
391       
392        self.JobId = sys.argv[1].strip()
393        self.UserName = sys.argv[2].strip()
394        self.Title = sys.argv[3].strip()
395        self.Copies = int(sys.argv[4].strip())
396        self.Options = sys.argv[5].strip()
397        if len(sys.argv) == 7 :
398            self.InputFile = sys.argv[6] # read job's datas from file
399        else :   
400            self.InputFile = None        # read job's datas from stdin
401           
402        self.RealBackend = backend
403        self.DeviceURI = device_uri
404        self.PrinterName = os.environ.get("PRINTER", "")
405        self.Directory = self.getPrintQueueOption(self.PrinterName, "directory")
406        self.DataFile = os.path.join(self.Directory, "%s-%s-%s-%s" % (self.myname, self.PrinterName, self.UserName, self.JobId))
407        self.ClientHost = self.extractJobOriginatingHostName()
408           
409    def getCupsConfigDirectives(self, directives=[]) :
410        """Retrieves some CUPS directives from its configuration file.
411       
412           Returns a mapping with lowercased directives as keys and
413           their setting as values.
414        """
415        dirvalues = {} 
416        cupsroot = os.environ.get("CUPS_SERVERROOT", "/etc/cups")
417        cupsdconf = os.path.join(cupsroot, "cupsd.conf")
418        try :
419            conffile = open(cupsdconf, "r")
420        except IOError :   
421            raise TeeError, "Unable to open %s" % cupsdconf
422        else :   
423            for line in conffile.readlines() :
424                linecopy = line.strip().lower()
425                for di in [d.lower() for d in directives] :
426                    if linecopy.startswith("%s " % di) :
427                        try :
428                            val = line.split()[1]
429                        except :   
430                            pass # ignore errors, we take the last value in any case.
431                        else :   
432                            dirvalues[di] = val
433            conffile.close()           
434        return dirvalues       
435           
436    def extractJobOriginatingHostName(self) :       
437        """Extracts the client's hostname or IP address from the CUPS message file for current job."""
438        cupsdconf = self.getCupsConfigDirectives(["RequestRoot"])
439        requestroot = cupsdconf.get("requestroot", "/var/spool/cups")
440        if (len(self.JobId) < 5) and self.JobId.isdigit() :
441            ippmessagefile = "c%05i" % int(self.JobId)
442        else :   
443            ippmessagefile = "c%s" % self.JobId
444        ippmessagefile = os.path.join(requestroot, ippmessagefile)
445        ippmessage = {}
446        try :
447            ippdatafile = open(ippmessagefile)
448        except :   
449            self.logInfo("Unable to open IPP message file %s" % ippmessagefile, "warn")
450        else :   
451            self.logDebug("Parsing of IPP message file %s begins." % ippmessagefile)
452            try :
453                ippmessage = IPPMessage(ippdatafile.read())
454            except IPPError, msg :   
455                self.logInfo("Error while parsing %s : %s" % (ippmessagefile, msg), "warn")
456            else :   
457                self.logDebug("Parsing of IPP message file %s ends." % ippmessagefile)
458            ippdatafile.close()
459        return ippmessage.get("job-originating-host-name")   
460               
461    def exportAttributes(self) :   
462        """Exports our backend's attributes to the environment."""
463        os.environ["DEVICE_URI"] = self.DeviceURI       # WARNING !
464        os.environ["TEAPRINTERNAME"] = self.PrinterName
465        os.environ["TEADIRECTORY"] = self.Directory
466        os.environ["TEADATAFILE"] = self.DataFile
467        os.environ["TEAJOBSIZE"] = str(self.JobSize)
468        os.environ["TEAMD5SUM"] = self.JobMD5Sum
469        os.environ["TEACLIENTHOST"] = self.ClientHost or ""
470        os.environ["TEAJOBID"] = self.JobId
471        os.environ["TEAUSERNAME"] = self.UserName
472        os.environ["TEATITLE"] = self.Title
473        os.environ["TEACOPIES"] = str(self.Copies)
474        os.environ["TEAOPTIONS"] = self.Options
475        os.environ["TEAINPUTFILE"] = self.InputFile or ""
476       
477    def saveDatasAndCheckSum(self) :
478        """Saves the input datas into a static file."""
479        self.logDebug("Duplicating data stream into %s" % self.DataFile)
480        mustclose = 0
481        if self.InputFile is not None :
482            infile = open(self.InputFile, "rb")
483            mustclose = 1
484        else :   
485            infile = sys.stdin
486        CHUNK = 64*1024         # read 64 Kb at a time
487        dummy = 0
488        sizeread = 0
489        checksum = md5.new()
490        outfile = open(self.DataFile, "wb")   
491        while 1 :
492            data = infile.read(CHUNK) 
493            if not data :
494                break
495            sizeread += len(data)   
496            outfile.write(data)
497            checksum.update(data)   
498            if not (dummy % 32) : # Only display every 2 Mb
499                self.logDebug("%s bytes saved..." % sizeread)
500            dummy += 1   
501        outfile.close()
502        if mustclose :   
503            infile.close()
504        self.JobSize = sizeread   
505        self.JobMD5Sum = checksum.hexdigest()
506        self.logDebug("Job %s is %s bytes long." % (self.JobId, self.JobSize))
507        self.logDebug("Job %s MD5 sum is %s" % (self.JobId, self.JobMD5Sum))
508
509    def cleanUp(self) :
510        """Cleans up the place."""
511        if not self.isTrue(self.getPrintQueueOption(self.PrinterName, "keepfiles", ignore=1)) :
512            os.remove(self.DataFile)
513           
514    def runBranches(self) :         
515        """Launches each tee defined for the current print queue."""
516        exitcode = 0
517        branches = self.enumTeeBranches(self.PrinterName)
518        if self.isTrue(self.getPrintQueueOption(self.PrinterName, "serialize", ignore=1)) :
519            self.logDebug("Serialized Tees")
520            for (branch, command) in branches.items() :
521                self.logDebug("Launching %s : %s" % (branch, command))
522                retcode = os.system(command)
523                self.logDebug("Exit code for tee %s on printer %s is %s" % (branch, self.PrinterName, retcode))
524                if os.WIFEXITED(retcode) :
525                    retcode = os.WEXITSTATUS(retcode)
526                if retcode :   
527                    self.logInfo("Tee %s on printer %s didn't exit successfully." % (branch, self.PrinterName), "error")
528                    exitcode = 1
529        else :       
530            self.logDebug("Forked Tees")
531            pids = {}
532            for (branch, command) in branches.items() :
533                pid = os.fork()
534                if pid :
535                    pids[branch] = pid
536                else :   
537                    self.logDebug("Launching %s : %s" % (branch, command))
538                    retcode = os.system(command)
539                    if os.WIFEXITED(retcode) :
540                        retcode = os.WEXITSTATUS(retcode)
541                    else :   
542                        retcode = -1
543                    sys.exit(retcode)
544            for (branch, pid) in pids.items() :
545                (childpid, retcode) = os.waitpid(pid, 0)
546                self.logDebug("Exit code for tee %s (PID %s) on printer %s is %s" % (branch, childpid, self.PrinterName, retcode))
547                if os.WIFEXITED(retcode) :
548                    retcode = os.WEXITSTATUS(retcode)
549                if retcode :   
550                    self.logInfo("Tee %s (PID %s) on printer %s didn't exit successfully." % (branch, childpid, self.PrinterName), "error")
551                    exitcode = 1
552        return exitcode
553       
554if __name__ == "__main__" :   
555    # This is a CUPS backend, we should act and die like a CUPS backend
556    wrapper = CupsBackend()
557    if len(sys.argv) == 1 :
558        print "\n".join(wrapper.discoverOtherBackends())
559        sys.exit(0)               
560    elif len(sys.argv) not in (6, 7) :   
561        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n"\
562                              % sys.argv[0])
563        sys.exit(1)
564    else :   
565        try :
566            wrapper.initBackend()
567            wrapper.saveDatasAndCheckSum()
568            wrapper.exportAttributes()
569            retcode = wrapper.runBranches()
570            wrapper.cleanUp()
571        except SystemExit, e :   
572            retcode = e.code
573        except :   
574            import traceback
575            lines = []
576            for line in traceback.format_exception(*sys.exc_info()) :
577                lines.extend([l for l in line.split("\n") if l])
578            msg = "ERROR: ".join(["%s (PID %s) : %s\n" % (wrapper.MyName, wrapper.pid, l) for l in (["ERROR: Tea4CUPS v%s" % version] + lines)])
579            sys.stderr.write(msg)
580            sys.stderr.flush()
581            retcode = 1
582        sys.exit(retcode)
Note: See TracBrowser for help on using the browser.