root / tea4cups / trunk / tea4cups @ 585

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

Added generic exception handling routine

  • Property svn:executable set to *
  • Property svn:keywords set to Author Date Id Rev
RevLine 
[565]1#! /usr/bin/env python
2# -*- coding: ISO-8859-15 -*-
3
[576]4# Tea4CUPS : Tee for CUPS
[565]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
[584]27import popen2
[568]28import errno
[577]29import md5
[565]30import cStringIO
31import shlex
[568]32import tempfile
[570]33import ConfigParser
[581]34from struct import unpack
[565]35
[585]36version = "0.99"
37
[570]38class TeeError(Exception):
[576]39    """Base exception for Tea4CUPS related stuff."""
[570]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   
[581]51class IPPError(TeeError) :   
52    """IPP related exceptions."""
53    pass 
54   
[584]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   
[582]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
[581]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           
[570]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       
[568]221class CupsBackend :
222    """Base class for tools with no database access."""
223    def __init__(self) :
224        """Initializes the CUPS backend wrapper."""
[577]225        self.MyName = "Tea4CUPS"
226        self.myname = "tea4cups"
227        self.pid = os.getpid()
[570]228        confdir = os.environ.get("CUPS_SERVERROOT", ".") 
[577]229        self.conffile = os.path.join(confdir, "%s.conf" % self.myname)
[570]230        if os.path.isfile(self.conffile) :
231            self.config = ConfigParser.ConfigParser()
232            self.config.read([self.conffile])
[573]233            self.debug = self.isTrue(self.getGlobalOption("debug", ignore=1))
[570]234        else :   
235            self.config = FakeConfig()
236            self.debug = 1      # no config, so force debug mode !
237           
[574]238    def logInfo(self, message, level="info") :       
239        """Logs a message to CUPS' error_log file."""
[585]240        sys.stderr.write("%s: %s v%s (PID %i) : %s\n" % (level.upper(), self.MyName, version, os.getpid(), message))
[574]241        sys.stderr.flush()
242       
[585]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       
[570]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) :   
[577]260            if not ignore :
[570]261                raise ConfigError, "Option %s not found in section global of %s" % (option, self.conffile)
262               
[577]263    def getPrintQueueOption(self, printqueuename, option, ignore=0) :   
[570]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 :
[574]267            return self.config.get(printqueuename, option, raw=1)
[570]268        except (ConfigParser.NoSectionError, ConfigParser.NoOptionError) :   
269            if globaloption is not None :
270                return globaloption
[577]271            elif not ignore :
[574]272                raise ConfigError, "Option %s not found in section [%s] of %s" % (option, printqueuename, self.conffile)
[571]273               
[574]274    def enumTeeBranches(self, printqueuename) :
[573]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 :   
[579]279            raise ConfigError, "Invalid configuration file : %s" % msg
[573]280        try :
[574]281            sectionbranches = [ (k, v) for (k, v) in self.config.items(printqueuename) if k.startswith("tee_") ]
[573]282        except ConfigParser.NoSectionError, msg :   
[583]283            self.logInfo("No section for print queue %s : %s" % (printqueuename, msg))
[573]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
[568]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        """
[569]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.
[568]307        available = []
[569]308        (directory, myname) = os.path.split(sys.argv[0])
[568]309        tmpdir = tempfile.gettempdir()
[569]310        lockfilename = os.path.join(tmpdir, "%s..LCK" % myname)
[568]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")
[577]325            lockfile.write("%i" % self.pid)
[568]326            lockfile.close()
[569]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 :                           
[568]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]
[577]364                            available.append('%s %s:%s "%s+%s" "%s managed %s"' \
365                                                 % (devicetype, self.myname, device, self.MyName, name, self.MyName, fullname))
[568]366            os.remove(lockfilename)
367        return available
368                       
[577]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):]
[583]380            for i in range(2) :
381                if device_uri.startswith("/") : 
382                    device_uri = device_uri[1:]
[577]383        try :
384            (backend, destination) = device_uri.split(":", 1) 
385        except ValueError :   
[583]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
[577]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")
[581]406        self.DataFile = os.path.join(self.Directory, "%s-%s-%s-%s" % (self.myname, self.PrinterName, self.UserName, self.JobId))
[582]407        self.ClientHost = self.extractJobOriginatingHostName()
[577]408           
[583]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           
[582]436    def extractJobOriginatingHostName(self) :       
437        """Extracts the client's hostname or IP address from the CUPS message file for current job."""
[583]438        cupsdconf = self.getCupsConfigDirectives(["RequestRoot"])
[582]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               
[577]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
[579]467        os.environ["TEAJOBSIZE"] = str(self.JobSize)
[577]468        os.environ["TEAMD5SUM"] = self.JobMD5Sum
[582]469        os.environ["TEACLIENTHOST"] = self.ClientHost or ""
[577]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."""
[583]479        self.logDebug("Duplicating data stream into %s" % self.DataFile)
[577]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 :   
[579]503            infile.close()
[577]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))
[568]508
[577]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)
[579]513           
514    def runBranches(self) :         
515        """Launches each tee defined for the current print queue."""
[584]516        exitcode = 0
[579]517        branches = self.enumTeeBranches(self.PrinterName)
[581]518        if self.isTrue(self.getPrintQueueOption(self.PrinterName, "serialize", ignore=1)) :
[584]519            self.logDebug("Serialized Tees")
[581]520            for (branch, command) in branches.items() :
521                self.logDebug("Launching %s : %s" % (branch, command))
[584]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
[581]529        else :       
[584]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
[577]553       
[565]554if __name__ == "__main__" :   
555    # This is a CUPS backend, we should act and die like a CUPS backend
[568]556    wrapper = CupsBackend()
[565]557    if len(sys.argv) == 1 :
[568]558        print "\n".join(wrapper.discoverOtherBackends())
559        sys.exit(0)               
[565]560    elif len(sys.argv) not in (6, 7) :   
[568]561        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n"\
562                              % sys.argv[0])
563        sys.exit(1)
[565]564    else :   
[585]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
[579]582        sys.exit(retcode)
Note: See TracBrowser for help on using the browser.