#!/usr/bin/env python3 # -*- coding: utf-8 -*- # vi: ft=python:tw=0:sw=4:ts=4:noet # Author: Jan Christoph Ebersbach # Last Modified: Thu 12. Jul 2012 20:16:13 +0200 CEST # dex # DesktopEntry Execution, is a program to generate and execute DesktopEntry # files of the type Application # # Depends: None # # Copyright (C) 2010, 2011, 2012, 2013 Jan Christoph Ebersbach # # http://www.e-jc.de/ # # All rights reserved. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program. If not, see . import glob import os import subprocess import sys __version__ = "0.7" # DesktopEntry exceptions class DesktopEntryTypeException(Exception): def __init__(self, value): self.value = value def __str__(self): return repr(self.value) class ApplicationExecException(Exception): def __init__(self, value): self.value = value Exception.__init__(self, value) def __str__(self): return repr(self.value) # DesktopEntry class definitions class DesktopEntry(object): """ Implements some parts of Desktop Entry specification: http://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-1.1.html """ def __init__(self, filename=None): """ @param filename Desktop Entry File """ if filename is not None and os.path.islink(filename) and \ os.readlink(filename) == os.path.sep + os.path.join('dev', 'null'): # ignore links to /dev/null pass elif filename is None or not os.path.isfile(filename): raise IOError('File does not exist: %s' % filename) self._filename = filename self.groups = {} def __str__(self): if self.Name: return self.Name elif self.filename: return self.filename return repr(self) def __lt__(self, y): return self.filename < y.filename @property def filename(self): """ The absolute filename """ return self._filename @classmethod def fromfile(cls, filename): """Create DesktopEntry for file @params filename Create a DesktopEntry object for file and determine the type automatically """ de = cls(filename=filename) # determine filetype de_type = 'Link' if os.path.exists(filename): if os.path.isdir(filename): de_type = 'Directory' # TODO fix the value for directories de.set_value('??', filename) else: de_type = 'Application' de.set_value('Exec', filename) de.set_value('Name', os.path.basename(filename)) if os.name == 'posix': whatis = subprocess.Popen(['whatis', filename], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = whatis.communicate() res = stdout.decode(sys.stdin.encoding).split('- ', 1) if len(res) == 2: de.set_value('Comment', res[1].split(os.linesep, 1)[0]) else: # type Link de.set_value('URL', filename) de.set_value('Type', de_type) return de def load(self): """Load or reload contents of desktop entry file""" self.groups = {} # clear settings grp_desktopentry = 'Desktop Entry' _f = open(self.filename, 'r') current_group = None try: for l in _f.readlines(): l = l.strip('\n') # handle comments and empty lines if l.startswith('#') or l.strip() == '': continue # handle groups if l.startswith('['): if not l.endswith(']'): raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because of line '%s'." % (self.filename, l)) group = l[1:-1] if self.groups.get(group, None): raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because group '%s' is specified multiple times." % (self.filename, group)) current_group = group continue # handle all the other lines if not current_group: raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because line '%s' does not belong to a group." % (self.filename, l)) kv = l.split('=', 1) if len(kv) != 2 or kv[0] == '': raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because line '%s' is not a valid key=value pair." % (self.filename, l)) k = kv[0] v = kv[1] # TODO: parse k for locale specific settings # TODO: parse v for multivalue fields self.set_value(k, v, current_group) except Exception as ex: _f.close() raise ex finally: _f.close() if grp_desktopentry not in self.groups: raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry group is missing." % (self.filename, )) if not (self.Type and self.Name): raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because Type or Name keys are missing." % (self.filename, )) _type = self.Type if _type == 'Application': if not self.Exec: raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry of type '%s' because Exec is missing." % (self.filename, _type)) elif _type == 'Link': if not self.URL: raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry of type '%s' because URL is missing." % (self.filename, _type)) elif _type == 'Directory': pass else: raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because Type '%s' is unkown." % (self.filename, self.Type)) # another name for load reload = load def write(self, fileobject): """Write DesktopEntry to a file @param fileobject DesktopEntry is written to file """ for group in self.groups: fileobject.write('[%s]\n' % (group, )) for key in self.groups[group]: fileobject.write('%s=%s\n' % (key, self.groups[group][key])) def set_value(self, key, value, group='Desktop Entry'): """ Set a key, value pair in group @param key Key @param value Value @param group The group key and value are set in. Default: Desktop Entry """ if group not in self.groups: self.groups[group] = {} self.groups[group][key] = str(value) return value def _get_value(self, key, group='Desktop Entry', default=None): if not self.groups: self.load() if group not in self.groups: raise KeyError("Group '%s' not found." % group) grp = self.groups[group] if key not in grp: return default return grp[key] def get_boolean(self, key, group='Desktop Entry', default=False): val = self._get_value(key, group=group, default=default) if type(val) == bool: return val if val in ['true', 'True']: return True if val in ['false', 'False']: return False raise ValueError("'%s's value '%s' in group '%s' is not a boolean value." % (key, val, group)) def get_list(self, key, group='Desktop Entry', default=None): list_of_strings = [] res = self.get_string(key, group=group, default=default) if type(res) == str: list_of_strings = [x for x in res.split(';') if x] return list_of_strings def get_string(self, key, group='Desktop Entry', default=''): return self._get_value(key, group=group, default=default) def get_strings(self, key, group='Desktop Entry', default=''): raise Exception("Not implemented yet.") def get_localestring(self, key, group='Desktop Entry', default=''): raise Exception("Not implemented yet.") def get_numeric(self, key, group='Desktop Entry', default=0.0): val = self._get_value(key, group=group, default=default) if type(val) == float: return val return float(val) @property def Type(self): return self.get_string('Type') @property def Version(self): return self.get_string('Version') @property def Name(self): # SHOULD be localestring! return self.get_string('Name') @property def GenericName(self): return self.get_localestring('GenericName') @property def NoDisplay(self): return self.get_boolean('NoDisplay') @property def Comment(self): return self.get_localestring('Comment') @property def Icon(self): return self.get_localestring('Icon') @property def Hidden(self): return self.get_boolean('Hidden') @property def OnlyShowIn(self): return self.get_list('OnlyShowIn') @property def NotShowIn(self): return self.get_list('NotShowIn') @property def TryExec(self): return self.get_string('TryExec') @property def Exec(self): return self.get_string('Exec') @property def Path(self): return self.get_string('Path') @property def Terminal(self): return self.get_boolean('Terminal') @property def MimeType(self): return self.get_strings('MimeType') @property def Categories(self): return self.get_strings('Categories') @property def StartupNotify(self): return self.get_boolean('StartupNotify') @property def StartupWMClass(self): return self.get_string('StartupWMClass') @property def URL(self): return self.get_string('URL') class Application(DesktopEntry): """ Implements application files """ def __init__(self, filename): """ @param filename Absolute path to a Desktop Entry File """ if not os.path.isabs(filename): filename = os.path.join(os.getcwd(), filename) super(Application, self).__init__(filename) self._basename = os.path.basename(filename) if self.Type != 'Application': raise DesktopEntryTypeException("'%s' is not of type 'Application'." % self.filename) def __cmp__(self, y): """ @param y The object to compare the current object with - comparison is made on the property of basename """ if isinstance(y, Application): return cmp(y.basename, self.basename) return -1 def __eq__(self, y): """ @param y The object to compare the current object with - comparison is made on the property of basename """ if isinstance(y, Application): return y.basename == self.basename return False @property def basename(self): """ The basename of file """ return self._basename @classmethod def _build_cmd(cls, exec_string, needs_terminal=False): """ # test single and multi argument commands >>> Application._build_cmd('gvim') ['gvim'] >>> Application._build_cmd('gvim test') ['gvim', 'test'] # test quotes >>> Application._build_cmd('"gvim" test') ['gvim', 'test'] >>> Application._build_cmd('"gvim test"') ['gvim test'] # test escape sequences >>> Application._build_cmd('"gvim test" test2 "test \\\\" 3"') ['gvim test', 'test2', 'test " 3'] >>> Application._build_cmd(r'"test \\\\\\\\ \\" moin" test') ['test \\\\ " moin', 'test'] >>> Application._build_cmd(r'"gvim \\\\\\\\ \\`test\\$"') ['gvim \\\\ `test$'] >>> Application._build_cmd(r'vim ~/.vimrc', True) ['x-terminal-emulator', '-e', 'vim', '~/.vimrc'] >>> Application._build_cmd('vim ~/.vimrc', False) ['vim', '~/.vimrc'] >>> Application._build_cmd("vim '~/.vimrc test'", False) ['vim', '~/.vimrc test'] >>> Application._build_cmd('vim \\'~/.vimrc " test\\'', False) ['vim', '~/.vimrc " test'] >>> Application._build_cmd('sh -c \\'vim ~/.vimrc " test\\'', False) ['sh', '-c', 'vim ~/.vimrc " test'] >>> Application._build_cmd("sh -c 'vim ~/.vimrc \\" test\\"'", False) ['sh', '-c', 'vim ~/.vimrc " test"'] # expand field codes by removing them >>> Application._build_cmd("vim %u", False) ['vim'] >>> Application._build_cmd("vim ~/.vimrc %u", False) ['vim', '~/.vimrc'] >>> Application._build_cmd("vim '%u' ~/.vimrc", False) ['vim', '%u', '~/.vimrc'] >>> Application._build_cmd("vim %u ~/.vimrc", False) ['vim', '~/.vimrc'] >>> Application._build_cmd("vim /%u/.vimrc", False) ['vim', '//.vimrc'] >>> Application._build_cmd("vim %u/.vimrc", False) ['vim', '/.vimrc'] >>> Application._build_cmd("vim %U/.vimrc", False) ['vim', '/.vimrc'] >>> Application._build_cmd("vim /%U/.vimrc", False) ['vim', '//.vimrc'] >>> Application._build_cmd("vim %U .vimrc", False) ['vim', '.vimrc'] # preserved escaped field codes >>> Application._build_cmd("vim \\\\%u ~/.vimrc", False) ['vim', '%u', '~/.vimrc'] # test for non-valid field codes, they should be preserved >>> Application._build_cmd("vim %x .vimrc", False) ['vim', '%x', '.vimrc'] >>> Application._build_cmd("vim %x/.vimrc", False) ['vim', '%x/.vimrc'] """ cmd = [] if needs_terminal: cmd += ['x-terminal-emulator', '-e'] _tmp = exec_string.replace('\\\\', '\\') _arg = '' in_esc = False in_quote = False in_singlequote = False in_fieldcode = False for c in _tmp: if in_esc: in_esc = False else: if in_fieldcode: in_fieldcode = False if c in ('u', 'U', 'f', 'F'): # TODO ignore field codes for the moment; at some point # field codes should be supported # strip %-char at the end of the argument _arg = _arg[:-1] continue if c == '"': if in_quote: in_quote = False cmd.append(_arg) _arg = '' continue if not in_singlequote: in_quote = True continue elif c == "'": if in_singlequote: in_singlequote = False cmd.append(_arg) _arg = '' continue if not in_quote: in_singlequote = True continue elif c == '\\': in_esc = True continue elif c == '%' and not (in_quote or in_singlequote): in_fieldcode = True elif c == ' ' and not (in_quote or in_singlequote): if not _arg: continue cmd.append(_arg) _arg = '' continue _arg += c if _arg and not (in_esc or in_quote or in_singlequote): cmd.append(_arg) elif _arg: raise ApplicationExecException('Exec value contains an unbalanced number of quote characters.') return cmd def execute(self, dryrun=False, verbose=False): """ Execute application @return Return subprocess.Popen object """ _exec = True _try = self.TryExec if _try and not (os.path.isabs(_try) and os.path.isfile(_try)) and not which(_try): _exec = False if _exec: path = self.Path cmd = self._build_cmd(self.Exec) if not cmd: raise ApplicationExecException('Failed to build command string.') if dryrun or verbose: if verbose: print('Autostart file: %s' % self.filename) if path: print('Changing directory to: ' + path) print('Executing command: ' + ' '.join(cmd)) if dryrun: return None if path: return subprocess.Popen(cmd, cwd=path) return subprocess.Popen(cmd) class AutostartFile(Application): """ Implements autostart files """ def __init__(self, filename): """ @param filename Absolute path to a Desktop Entry File """ super(AutostartFile, self).__init__(filename) class EmptyAutostartFile(Application): """ Workaround for empty autostart files that don't contain the necessary data """ def __init__(self, filename): """ @param filename Absolute path to a Desktop Entry File """ try: super(EmptyAutostartFile, self).__init__(filename) except DesktopEntryTypeException: # ignore the missing type information pass # local methods def which(filename): path = os.environ.get('PATH', None) if path: for _p in path.split(os.pathsep): _f = os.path.join(_p, filename) if os.path.isfile(_f): return _f def get_autostart_directories(): """ Generate the list of autostart directories """ autostart_directories = [] # autostart directories, ordered by preference if args.searchdir: autostart_directories.append(args.searchdir[0]) else: # generate list of autostart directories if os.environ.get('XDG_CONFIG_HOME', None): autostart_directories.append(os.path.join(os.environ.get('XDG_CONFIG_HOME'), 'autostart')) else: autostart_directories.append(os.path.join(os.environ['HOME'], '.config', 'autostart')) if os.environ.get('XDG_CONFIG_DIRS', None): for d in os.environ['XDG_CONFIG_DIRS'].split(os.pathsep): if not d: continue autostart_dir = os.path.join(d, 'autostart') if autostart_dir not in autostart_directories: autostart_directories.append(autostart_dir) else: autostart_directories.append(os.path.sep + os.path.join('etc', 'xdg', 'autostart')) return autostart_directories def get_autostart_files(args, verbose=False): """ Generate a list of autostart files according to autostart-spec 0.5 TODO: do filetype recognition according to spec """ environment = args.environment[0].lower() if args.environment else '' autostart_files = [] # autostart files, excluding files marked as hidden non_autostart_files = [] for d in get_autostart_directories(): for _f in glob.glob1(d, '*.desktop'): _f = os.path.join(d, _f) af = None if os.path.isfile(_f) or os.path.islink(_f): try: af = AutostartFile(_f) except DesktopEntryTypeException as ex: af = EmptyAutostartFile(_f) if af not in autostart_files and not af in non_autostart_files: non_autostart_files.append(af) continue except ValueError as ex: if verbose: print(ex, file=sys.stderr) continue except IOError as ex: if verbose: print(ex, file=sys.stderr) continue else: if verbose: print('Ignoring unknown file: %s' % _f, file=sys.stderr) continue if verbose: if af.NotShowIn: print('Not show in environments %s: %s' % (', '.join(af.NotShowIn), af.filename), file=sys.stderr) if af.OnlyShowIn: print('Only show in environments %s: %s' % (', '.join(af.OnlyShowIn), af.filename), file=sys.stderr) if af in autostart_files or af in non_autostart_files: if verbose: print('Ignoring file, overridden by other autostart file: %s' % af.filename, file=sys.stderr) continue elif af.Hidden: if verbose: print('Ignoring file, hidden attribute is set: %s' % af.filename, file=sys.stderr) non_autostart_files.append(af) continue elif environment: if environment in [x.lower() for x in af.NotShowIn]: if verbose: print('Ignoring file, it must not start in specific environments (%s): %s' % (', '.join(af.NotShowIn), af.filename), file=sys.stderr) non_autostart_files.append(af) continue elif af.OnlyShowIn and environment not in [x.lower() for x in af.OnlyShowIn]: if verbose: print('Ignoring file, it must only start in specific environments (%s): %s' % (', '.join(af.OnlyShowIn), af.filename), file=sys.stderr) non_autostart_files.append(af) continue autostart_files.append(af) if verbose: for i in non_autostart_files: print('Ignoring empty file: %s' % i.filename, file=sys.stderr) return sorted(autostart_files) def _test(args): """ run tests """ import doctest doctest.testmod() def _autostart(args): """ perform autostart """ if args.dryrun and args.verbose: print('Dry run, nothing is executed.', file=sys.stderr) exit_value = 0 for app in get_autostart_files(args, verbose=args.verbose): try: app.execute(dryrun=args.dryrun, verbose=args.verbose) except Exception as ex: exit_value = 1 print("Execution faild: %s%s%s" % (app.filename, os.linesep, ex), file=sys.stderr) def _run(args): """ execute specified DesktopEntry files """ if args.dryrun and args.verbose: print('Dry run, nothing is executed.', file=sys.stderr) exit_value = 0 if not args.files: print("Nothing to execute, no DesktopEntry files specified!", file=sys.stderr) parser.print_help() exit_value = 1 else: for f in args.files: try: app = Application(f) app.execute(dryrun=args.dryrun, verbose=args.verbose) except ValueError as ex: print(ex, file=sys.stderr) except IOError as ex: print(ex, file=sys.stderr) except Exception as ex: exit_value = 1 print("Execution faild: %s%s%s" % (f, os.linesep, ex), file=sys.stderr) return exit_value def _create(args): """ create a new DesktopEntry file from the given argument """ target = args.create[0] if args.verbose: print('Creating DesktopEntry for file %s.' % target) de = DesktopEntry.fromfile(target) if args.verbose: print('Type: %s' % de.Type) # determine output file output = '.'.join((os.path.basename(target), 'directory' if de.Type == 'Directory' else 'desktop')) if args.targetdir: output = os.path.join(args.targetdir[0], output) elif len(args.create) > 1: output = args.create[1] if args.verbose: print('Output: %s' % output) targetfile = sys.stdout if output == '-' else open(output, 'w') de.write(targetfile) if args.targetdir and len(args.create) > 1: args.create = args.create[1:] return _create(args) return 0 # start execution if __name__ == '__main__': from argparse import ArgumentParser parser = ArgumentParser(usage='%(prog)s [options] [DesktopEntryFile [DesktopEntryFile ...]]', description='dex, DesktopEntry Execution, is a program to generate and execute DesktopEntry files of the type Application', epilog='Example usage: list autostart programs: dex -ad') parser.add_argument("--test", action="store_true", dest="test", help="perform a self-test") parser.add_argument("-v", "--verbose", action="store_true", dest="verbose", help="verbose output") parser.add_argument("-V", "--version", action="store_true", dest="version", help="display version information") parser.add_argument('files', nargs='*', help="DesktopEntry files") run = parser.add_argument_group('run') run.add_argument("-a", "--autostart", action="store_true", dest="autostart", help="autostart programs") run.add_argument("-d", "--dry-run", action="store_true", dest="dryrun", help="dry run, don't execute any command") run.add_argument("-e", "--environment", nargs=1, dest="environment", help="specify the Desktop Environment an autostart should be performed for; works only in combination with --autostart") run.add_argument("-s", "--search-directory", nargs=1, dest="searchdir", help="specify the directory to search for desktop files in, overriding the default search list") create = parser.add_argument_group('create') create.add_argument("-c", "--create", nargs='+', dest="create", help="create a DesktopEntry file for the given program. If a second argument is provided it's taken as output filename or written to stdout (filname: -). By default a new file with the postfix .desktop is created") create.add_argument("-t", "--target-directory", nargs=1, dest="targetdir", help="create files in target directory") parser.set_defaults(func=_run, dryrun=False, test=False, autostart=False, verbose=False) args = parser.parse_args() if args.autostart: args.func = _autostart elif args.create: args.func = _create elif args.test: args.func = _test # display version information if args.version: print("dex %s" % __version__) else: sys.exit(args.func(args))