root / tea4cups / trunk / tea4cups @ 584

Revision 584, 23.5 kB (checked in by jerome, 14 years ago)

Forked tees work fine now

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