diff --git a/libs/xdg/BaseDirectory.py b/libs/xdg/BaseDirectory.py index cececa3c..a7c31b1b 100644 --- a/libs/xdg/BaseDirectory.py +++ b/libs/xdg/BaseDirectory.py @@ -25,7 +25,7 @@ Typical usage: Note: see the rox.Options module for a higher-level API for managing options. """ -import os +import os, stat _home = os.path.expanduser('~') xdg_data_home = os.environ.get('XDG_DATA_HOME') or \ @@ -131,15 +131,30 @@ def get_runtime_dir(strict=True): import getpass fallback = '/tmp/pyxdg-runtime-dir-fallback-' + getpass.getuser() + create = False + try: - os.mkdir(fallback, 0o700) + # This must be a real directory, not a symlink, so attackers can't + # point it elsewhere. So we use lstat to check it. + st = os.lstat(fallback) except OSError as e: import errno - if e.errno == errno.EEXIST: - # Already exists - set 700 permissions again. - import stat - os.chmod(fallback, stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR) - else: # pragma: no cover + if e.errno == errno.ENOENT: + create = True + else: raise - + else: + # The fallback must be a directory + if not stat.S_ISDIR(st.st_mode): + os.unlink(fallback) + create = True + # Must be owned by the user and not accessible by anyone else + elif (st.st_uid != os.getuid()) \ + or (st.st_mode & (stat.S_IRWXG | stat.S_IRWXO)): + os.rmdir(fallback) + create = True + + if create: + os.mkdir(fallback, 0o700) + return fallback diff --git a/libs/xdg/DesktopEntry.py b/libs/xdg/DesktopEntry.py index d50640a3..84e6dd9c 100644 --- a/libs/xdg/DesktopEntry.py +++ b/libs/xdg/DesktopEntry.py @@ -1,5 +1,5 @@ """ -Complete implementation of the XDG Desktop Entry Specification Version 0.9.4 +Complete implementation of the XDG Desktop Entry Specification http://standards.freedesktop.org/desktop-entry-spec/ Not supported: @@ -13,6 +13,7 @@ Not supported: from xdg.IniFile import IniFile, is_ascii import xdg.Locale from xdg.Exceptions import ParsingError +from xdg.util import which import os.path import re import warnings @@ -23,7 +24,7 @@ class DesktopEntry(IniFile): defaultGroup = 'Desktop Entry' def __init__(self, filename=None): - """Create a new DesktopEntry + """Create a new DesktopEntry. If filename exists, it will be parsed as a desktop entry file. If not, or if filename is None, a blank DesktopEntry is created. @@ -38,8 +39,22 @@ class DesktopEntry(IniFile): return self.getName() def parse(self, file): - """Parse a desktop entry file.""" + """Parse a desktop entry file. + + This can raise :class:`~xdg.Exceptions.ParsingError`, + :class:`~xdg.Exceptions.DuplicateGroupError` or + :class:`~xdg.Exceptions.DuplicateKeyError`. + """ IniFile.parse(self, file, ["Desktop Entry", "KDE Desktop Entry"]) + + def findTryExec(self): + """Looks in the PATH for the executable given in the TryExec field. + + Returns the full path to the executable if it is found, None if not. + Raises :class:`~xdg.Exceptions.NoKeyError` if TryExec is not present. + """ + tryexec = self.get('TryExec', strict=True) + return which(tryexec) # start standard keys def getType(self): @@ -140,10 +155,11 @@ class DesktopEntry(IniFile): # desktop entry edit stuff def new(self, filename): - """Make this instance into a new desktop entry. + """Make this instance into a new, blank desktop entry. If filename has a .desktop extension, Type is set to Application. If it - has a .directory extension, Type is Directory. + has a .directory extension, Type is Directory. Other extensions will + cause :class:`~xdg.Exceptions.ParsingError` to be raised. """ if os.path.splitext(filename)[1] == ".desktop": type = "Application" @@ -185,7 +201,7 @@ class DesktopEntry(IniFile): def checkGroup(self, group): # check if group header is valid if not (group == self.defaultGroup \ - or re.match("^Desktop Action [a-zA-Z0-9\-]+$", group) \ + or re.match("^Desktop Action [a-zA-Z0-9-]+$", group) \ or (re.match("^X-", group) and is_ascii(group))): self.errors.append("Invalid Group name: %s" % group) else: diff --git a/libs/xdg/Exceptions.py b/libs/xdg/Exceptions.py index f7d08be4..7096b614 100644 --- a/libs/xdg/Exceptions.py +++ b/libs/xdg/Exceptions.py @@ -5,6 +5,7 @@ Exception Classes for the xdg package debug = False class Error(Exception): + """Base class for exceptions defined here.""" def __init__(self, msg): self.msg = msg Exception.__init__(self, msg) @@ -12,40 +13,72 @@ class Error(Exception): return self.msg class ValidationError(Error): + """Raised when a file fails to validate. + + The filename is the .file attribute. + """ def __init__(self, msg, file): self.msg = msg self.file = file Error.__init__(self, "ValidationError in file '%s': %s " % (file, msg)) class ParsingError(Error): + """Raised when a file cannot be parsed. + + The filename is the .file attribute. + """ def __init__(self, msg, file): self.msg = msg self.file = file Error.__init__(self, "ParsingError in file '%s', %s" % (file, msg)) class NoKeyError(Error): + """Raised when trying to access a nonexistant key in an INI-style file. + + Attributes are .key, .group and .file. + """ def __init__(self, key, group, file): Error.__init__(self, "No key '%s' in group %s of file %s" % (key, group, file)) self.key = key self.group = group + self.file = file class DuplicateKeyError(Error): + """Raised when the same key occurs twice in an INI-style file. + + Attributes are .key, .group and .file. + """ def __init__(self, key, group, file): Error.__init__(self, "Duplicate key '%s' in group %s of file %s" % (key, group, file)) self.key = key self.group = group + self.file = file class NoGroupError(Error): + """Raised when trying to access a nonexistant group in an INI-style file. + + Attributes are .group and .file. + """ def __init__(self, group, file): Error.__init__(self, "No group: %s in file %s" % (group, file)) self.group = group + self.file = file class DuplicateGroupError(Error): + """Raised when the same key occurs twice in an INI-style file. + + Attributes are .group and .file. + """ def __init__(self, group, file): Error.__init__(self, "Duplicate group: %s in file %s" % (group, file)) self.group = group + self.file = file class NoThemeError(Error): + """Raised when trying to access a nonexistant icon theme. + + The name of the theme is the .theme attribute. + """ def __init__(self, theme): Error.__init__(self, "No such icon-theme: %s" % theme) self.theme = theme diff --git a/libs/xdg/IconTheme.py b/libs/xdg/IconTheme.py index aa88e009..bda8b8f0 100644 --- a/libs/xdg/IconTheme.py +++ b/libs/xdg/IconTheme.py @@ -1,5 +1,5 @@ """ -Complete implementation of the XDG Icon Spec Version 0.8 +Complete implementation of the XDG Icon Spec http://standards.freedesktop.org/icon-theme-spec/ """ @@ -37,6 +37,8 @@ class IconTheme(IniFile): return self.get('Inherits', list=True) def getDirectories(self): return self.get('Directories', list=True) + def getScaledDirectories(self): + return self.get('ScaledDirectories', list=True) def getHidden(self): return self.get('Hidden', type="boolean") def getExample(self): @@ -72,6 +74,10 @@ class IconTheme(IniFile): else: return 2 + def getScale(self, directory): + value = self.get('Scale', type="integer", group=directory) + return value or 1 + # validation stuff def checkExtras(self): # header @@ -125,7 +131,7 @@ class IconTheme(IniFile): self.name = self.content[group]["Size"] except KeyError: self.errors.append("Key 'Size' in Group '%s' is missing" % group) - elif not (re.match("^\[X-", group) and is_ascii(group)): + elif not (re.match(r"^\[X-", group) and is_ascii(group)): self.errors.append("Invalid Group name: %s" % group) def checkKey(self, key, value, group): @@ -139,6 +145,8 @@ class IconTheme(IniFile): self.checkValue(key, value, list=True) elif key == "Directories": self.checkValue(key, value, list=True) + elif key == "ScaledDirectories": + self.checkValue(key, value, list=True) elif key == "Hidden": self.checkValue(key, value, type="boolean") elif key == "Example": @@ -168,6 +176,8 @@ class IconTheme(IniFile): self.checkValue(key, value, type="integer") if self.type != "Threshold": self.errors.append("Key 'Threshold' give, but Type is %s" % self.type) + elif key == "Scale": + self.checkValue(key, value, type="integer") elif re.match("^X-[a-zA-Z0-9-]+", key): pass else: @@ -211,7 +221,7 @@ class IconData(IniFile): def checkGroup(self, group): # check if group header is valid if not (group == self.defaultGroup \ - or (re.match("^\[X-", group) and is_ascii(group))): + or (re.match(r"^\[X-", group) and is_ascii(group))): self.errors.append("Invalid Group name: %s" % group.encode("ascii", "replace")) def checkKey(self, key, value, group): diff --git a/libs/xdg/IniFile.py b/libs/xdg/IniFile.py index de6dcbf5..718589f9 100644 --- a/libs/xdg/IniFile.py +++ b/libs/xdg/IniFile.py @@ -102,7 +102,7 @@ class IniFile: raise ParsingError("[%s]-Header missing" % headers[0], filename) # start stuff to access the keys - def get(self, key, group=None, locale=False, type="string", list=False): + def get(self, key, group=None, locale=False, type="string", list=False, strict=False): # set default group if not group: group = self.defaultGroup @@ -114,7 +114,7 @@ class IniFile: else: value = self.content[group][key] else: - if debug: + if strict or debug: if group not in self.content: raise NoGroupError(group, self.filename) elif key not in self.content[group]: @@ -192,8 +192,8 @@ class IniFile: # start validation stuff def validate(self, report="All"): - """Validate the contents, raising ``ValidationError`` if there - is anything amiss. + """Validate the contents, raising :class:`~xdg.Exceptions.ValidationError` + if there is anything amiss. report can be 'All' / 'Warnings' / 'Errors' """ diff --git a/libs/xdg/Locale.py b/libs/xdg/Locale.py index d30d91a6..d0a70d2a 100644 --- a/libs/xdg/Locale.py +++ b/libs/xdg/Locale.py @@ -9,7 +9,7 @@ http://cvs.sourceforge.net/viewcvs.py/rox/ROX-Lib2/python/rox/i18n.py?rev=1.3&vi import os from locale import normalize -regex = "(\[([a-zA-Z]+)(_[a-zA-Z]+)?(\.[a-zA-Z\-0-9]+)?(@[a-zA-Z]+)?\])?" +regex = r"(\[([a-zA-Z]+)(_[a-zA-Z]+)?(\.[a-zA-Z0-9-]+)?(@[a-zA-Z]+)?\])?" def _expand_lang(locale): locale = normalize(locale) diff --git a/libs/xdg/Menu.py b/libs/xdg/Menu.py index 6252c800..1d03cad5 100644 --- a/libs/xdg/Menu.py +++ b/libs/xdg/Menu.py @@ -1,5 +1,5 @@ """ -Implementation of the XDG Menu Specification Version 1.0.draft-1 +Implementation of the XDG Menu Specification http://standards.freedesktop.org/menu-spec/ Example code: @@ -17,32 +17,46 @@ def print_menu(menu, tab=0): print_menu(parse()) """ -import locale, os, xml.dom.minidom +import os +import locale import subprocess +import ast +try: + import xml.etree.cElementTree as etree +except ImportError: + import xml.etree.ElementTree as etree from xdg.BaseDirectory import xdg_data_dirs, xdg_config_dirs from xdg.DesktopEntry import DesktopEntry -from xdg.Exceptions import ParsingError, ValidationError, debug +from xdg.Exceptions import ParsingError from xdg.util import PY3 import xdg.Locale import xdg.Config -ELEMENT_NODE = xml.dom.Node.ELEMENT_NODE def _strxfrm(s): """Wrapper around locale.strxfrm that accepts unicode strings on Python 2. - + See Python bug #2481. """ if (not PY3) and isinstance(s, unicode): s = s.encode('utf-8') return locale.strxfrm(s) + +DELETED = "Deleted" +NO_DISPLAY = "NoDisplay" +HIDDEN = "Hidden" +EMPTY = "Empty" +NOT_SHOW_IN = "NotShowIn" +NO_EXEC = "NoExec" + + class Menu: """Menu containing sub menus under menu.Entries - Contains both Menu and MenuEntry items. + Contains both Menu and MenuEntry items. """ def __init__(self): # Public stuff @@ -55,20 +69,20 @@ class Menu: self.Parent = None self.NotInXml = False - # Can be one of Deleted/NoDisplay/Hidden/Empty/NotShowIn or True + # Can be True, False, DELETED, NO_DISPLAY, HIDDEN, EMPTY or NOT_SHOW_IN self.Show = True self.Visible = 0 # Private stuff, only needed for parsing self.AppDirs = [] self.DefaultLayout = None - self.Deleted = "notset" + self.Deleted = None self.Directories = [] self.DirectoryDirs = [] self.Layout = None self.MenuEntries = [] self.Moves = [] - self.OnlyUnallocated = "notset" + self.OnlyUnallocated = None self.Rules = [] self.Submenus = [] @@ -85,10 +99,10 @@ class Menu: for directory in other.Directories: self.Directories.append(directory) - if other.Deleted != "notset": + if other.Deleted is not None: self.Deleted = other.Deleted - if other.OnlyUnallocated != "notset": + if other.OnlyUnallocated is not None: self.OnlyUnallocated = other.OnlyUnallocated if other.Layout: @@ -111,11 +125,11 @@ class Menu: # FIXME: Performance: cache getName() def __cmp__(self, other): return locale.strcoll(self.getName(), other.getName()) - + def _key(self): """Key function for locale-aware sorting.""" return _strxfrm(self.getName()) - + def __lt__(self, other): try: other = other._key() @@ -130,24 +144,24 @@ class Menu: return self.Name == str(other) """ PUBLIC STUFF """ - def getEntries(self, hidden=False): + def getEntries(self, show_hidden=False): """Interator for a list of Entries visible to the user.""" for entry in self.Entries: - if hidden == True: + if show_hidden: yield entry - elif entry.Show == True: + elif entry.Show is True: yield entry # FIXME: Add searchEntry/seaqrchMenu function - # search for name/comment/genericname/desktopfileide + # search for name/comment/genericname/desktopfileid # return multiple items - def getMenuEntry(self, desktopfileid, deep = False): + def getMenuEntry(self, desktopfileid, deep=False): """Searches for a MenuEntry with a given DesktopFileID.""" for menuentry in self.MenuEntries: if menuentry.DesktopFileID == desktopfileid: return menuentry - if deep == True: + if deep: for submenu in self.Submenus: submenu.getMenuEntry(desktopfileid, deep) @@ -164,7 +178,7 @@ class Menu: def getPath(self, org=False, toplevel=False): """Returns this menu's path in the menu structure.""" parent = self - names=[] + names = [] while 1: if org: names.append(parent.Name) @@ -176,7 +190,7 @@ class Menu: break names.reverse() path = "" - if toplevel == False: + if not toplevel: names.pop(0) for name in names: path = os.path.join(path, name) @@ -210,6 +224,106 @@ class Menu: except AttributeError: return "" + def sort(self): + self.Entries = [] + self.Visible = 0 + + for submenu in self.Submenus: + submenu.sort() + + _submenus = set() + _entries = set() + + for order in self.Layout.order: + if order[0] == "Filename": + _entries.add(order[1]) + elif order[0] == "Menuname": + _submenus.add(order[1]) + + for order in self.Layout.order: + if order[0] == "Separator": + separator = Separator(self) + if len(self.Entries) > 0 and isinstance(self.Entries[-1], Separator): + separator.Show = False + self.Entries.append(separator) + elif order[0] == "Filename": + menuentry = self.getMenuEntry(order[1]) + if menuentry: + self.Entries.append(menuentry) + elif order[0] == "Menuname": + submenu = self.getMenu(order[1]) + if submenu: + if submenu.Layout.inline: + self.merge_inline(submenu) + else: + self.Entries.append(submenu) + elif order[0] == "Merge": + if order[1] == "files" or order[1] == "all": + self.MenuEntries.sort() + for menuentry in self.MenuEntries: + if menuentry.DesktopFileID not in _entries: + self.Entries.append(menuentry) + elif order[1] == "menus" or order[1] == "all": + self.Submenus.sort() + for submenu in self.Submenus: + if submenu.Name not in _submenus: + if submenu.Layout.inline: + self.merge_inline(submenu) + else: + self.Entries.append(submenu) + + # getHidden / NoDisplay / OnlyShowIn / NotOnlyShowIn / Deleted / NoExec + for entry in self.Entries: + entry.Show = True + self.Visible += 1 + if isinstance(entry, Menu): + if entry.Deleted is True: + entry.Show = DELETED + self.Visible -= 1 + elif isinstance(entry.Directory, MenuEntry): + if entry.Directory.DesktopEntry.getNoDisplay(): + entry.Show = NO_DISPLAY + self.Visible -= 1 + elif entry.Directory.DesktopEntry.getHidden(): + entry.Show = HIDDEN + self.Visible -= 1 + elif isinstance(entry, MenuEntry): + if entry.DesktopEntry.getNoDisplay(): + entry.Show = NO_DISPLAY + self.Visible -= 1 + elif entry.DesktopEntry.getHidden(): + entry.Show = HIDDEN + self.Visible -= 1 + elif entry.DesktopEntry.getTryExec() and not entry.DesktopEntry.findTryExec(): + entry.Show = NO_EXEC + self.Visible -= 1 + elif xdg.Config.windowmanager: + if (entry.DesktopEntry.OnlyShowIn != [] and ( + xdg.Config.windowmanager not in entry.DesktopEntry.OnlyShowIn + ) + ) or ( + xdg.Config.windowmanager in entry.DesktopEntry.NotShowIn + ): + entry.Show = NOT_SHOW_IN + self.Visible -= 1 + elif isinstance(entry, Separator): + self.Visible -= 1 + # remove separators at the beginning and at the end + if len(self.Entries) > 0: + if isinstance(self.Entries[0], Separator): + self.Entries[0].Show = False + if len(self.Entries) > 1: + if isinstance(self.Entries[-1], Separator): + self.Entries[-1].Show = False + + # show_empty tag + for entry in self.Entries[:]: + if isinstance(entry, Menu) and not entry.Layout.show_empty and entry.Visible == 0: + entry.Show = EMPTY + self.Visible -= 1 + if entry.NotInXml is True: + self.Entries.remove(entry) + """ PRIVATE STUFF """ def addSubmenu(self, newmenu): for submenu in self.Submenus: @@ -221,211 +335,121 @@ class Menu: newmenu.Parent = self newmenu.Depth = self.Depth + 1 + # inline tags + def merge_inline(self, submenu): + """Appends a submenu's entries to this menu + See the section of the spec about the "inline" attribute + """ + if len(submenu.Entries) == 1 and submenu.Layout.inline_alias: + menuentry = submenu.Entries[0] + menuentry.DesktopEntry.set("Name", submenu.getName(), locale=True) + menuentry.DesktopEntry.set("GenericName", submenu.getGenericName(), locale=True) + menuentry.DesktopEntry.set("Comment", submenu.getComment(), locale=True) + self.Entries.append(menuentry) + elif len(submenu.Entries) <= submenu.Layout.inline_limit or submenu.Layout.inline_limit == 0: + if submenu.Layout.inline_header: + header = Header(submenu.getName(), submenu.getGenericName(), submenu.getComment()) + self.Entries.append(header) + for entry in submenu.Entries: + self.Entries.append(entry) + else: + self.Entries.append(submenu) + + class Move: "A move operation" - def __init__(self, node=None): - if node: - self.parseNode(node) - else: - self.Old = "" - self.New = "" + def __init__(self, old="", new=""): + self.Old = old + self.New = new def __cmp__(self, other): return cmp(self.Old, other.Old) - def parseNode(self, node): - for child in node.childNodes: - if child.nodeType == ELEMENT_NODE: - if child.tagName == "Old": - try: - self.parseOld(child.childNodes[0].nodeValue) - except IndexError: - raise ValidationError('Old cannot be empty', '??') - elif child.tagName == "New": - try: - self.parseNew(child.childNodes[0].nodeValue) - except IndexError: - raise ValidationError('New cannot be empty', '??') - - def parseOld(self, value): - self.Old = value - def parseNew(self, value): - self.New = value - class Layout: "Menu Layout class" - def __init__(self, node=None): - self.order = [] - if node: - self.show_empty = node.getAttribute("show_empty") or "false" - self.inline = node.getAttribute("inline") or "false" - self.inline_limit = node.getAttribute("inline_limit") or 4 - self.inline_header = node.getAttribute("inline_header") or "true" - self.inline_alias = node.getAttribute("inline_alias") or "false" - self.inline_limit = int(self.inline_limit) - self.parseNode(node) - else: - self.show_empty = "false" - self.inline = "false" - self.inline_limit = 4 - self.inline_header = "true" - self.inline_alias = "false" - self.order.append(["Merge", "menus"]) - self.order.append(["Merge", "files"]) + def __init__(self, show_empty=False, inline=False, inline_limit=4, + inline_header=True, inline_alias=False): + self.show_empty = show_empty + self.inline = inline + self.inline_limit = inline_limit + self.inline_header = inline_header + self.inline_alias = inline_alias + self._order = [] + self._default_order = [ + ['Merge', 'menus'], + ['Merge', 'files'] + ] - def parseNode(self, node): - for child in node.childNodes: - if child.nodeType == ELEMENT_NODE: - if child.tagName == "Menuname": - try: - self.parseMenuname( - child.childNodes[0].nodeValue, - child.getAttribute("show_empty") or "false", - child.getAttribute("inline") or "false", - child.getAttribute("inline_limit") or 4, - child.getAttribute("inline_header") or "true", - child.getAttribute("inline_alias") or "false" ) - except IndexError: - raise ValidationError('Menuname cannot be empty', "") - elif child.tagName == "Separator": - self.parseSeparator() - elif child.tagName == "Filename": - try: - self.parseFilename(child.childNodes[0].nodeValue) - except IndexError: - raise ValidationError('Filename cannot be empty', "") - elif child.tagName == "Merge": - self.parseMerge(child.getAttribute("type") or "all") + @property + def order(self): + return self._order if self._order else self._default_order - def parseMenuname(self, value, empty="false", inline="false", inline_limit=4, inline_header="true", inline_alias="false"): - self.order.append(["Menuname", value, empty, inline, inline_limit, inline_header, inline_alias]) - self.order[-1][4] = int(self.order[-1][4]) - - def parseSeparator(self): - self.order.append(["Separator"]) - - def parseFilename(self, value): - self.order.append(["Filename", value]) - - def parseMerge(self, type="all"): - self.order.append(["Merge", type]) + @order.setter + def order(self, order): + self._order = order class Rule: - "Inlcude / Exclude Rules Class" - def __init__(self, type, node=None): - # Type is Include or Exclude + """Include / Exclude Rules Class""" + + TYPE_INCLUDE, TYPE_EXCLUDE = 0, 1 + + @classmethod + def fromFilename(cls, type, filename): + tree = ast.Expression( + body=ast.Compare( + left=ast.Str(filename), + ops=[ast.Eq()], + comparators=[ast.Attribute( + value=ast.Name(id='menuentry', ctx=ast.Load()), + attr='DesktopFileID', + ctx=ast.Load() + )] + ), + lineno=1, col_offset=0 + ) + ast.fix_missing_locations(tree) + rule = Rule(type, tree) + return rule + + def __init__(self, type, expression): + # Type is TYPE_INCLUDE or TYPE_EXCLUDE self.Type = type - # Rule is a python expression - self.Rule = "" - - # Private attributes, only needed for parsing - self.Depth = 0 - self.Expr = [ "or" ] - self.New = True - - # Begin parsing - if node: - self.parseNode(node) + # expression is ast.Expression + self.expression = expression + self.code = compile(self.expression, '', 'eval') def __str__(self): - return self.Rule - - def do(self, menuentries, type, run): + return ast.dump(self.expression) + + def apply(self, menuentries, run): for menuentry in menuentries: - if run == 2 and ( menuentry.MatchedInclude == True \ - or menuentry.Allocated == True ): + if run == 2 and (menuentry.MatchedInclude is True or + menuentry.Allocated is True): continue - elif eval(self.Rule): - if type == "Include": + if eval(self.code): + if self.Type is Rule.TYPE_INCLUDE: menuentry.Add = True menuentry.MatchedInclude = True else: menuentry.Add = False return menuentries - def parseNode(self, node): - for child in node.childNodes: - if child.nodeType == ELEMENT_NODE: - if child.tagName == 'Filename': - try: - self.parseFilename(child.childNodes[0].nodeValue) - except IndexError: - raise ValidationError('Filename cannot be empty', "???") - elif child.tagName == 'Category': - try: - self.parseCategory(child.childNodes[0].nodeValue) - except IndexError: - raise ValidationError('Category cannot be empty', "???") - elif child.tagName == 'All': - self.parseAll() - elif child.tagName == 'And': - self.parseAnd(child) - elif child.tagName == 'Or': - self.parseOr(child) - elif child.tagName == 'Not': - self.parseNot(child) - - def parseNew(self, set=True): - if not self.New: - self.Rule += " " + self.Expr[self.Depth] + " " - if not set: - self.New = True - elif set: - self.New = False - - def parseFilename(self, value): - self.parseNew() - self.Rule += "menuentry.DesktopFileID == '%s'" % value.strip().replace("\\", r"\\").replace("'", r"\'") - - def parseCategory(self, value): - self.parseNew() - self.Rule += "'%s' in menuentry.Categories" % value.strip() - - def parseAll(self): - self.parseNew() - self.Rule += "True" - - def parseAnd(self, node): - self.parseNew(False) - self.Rule += "(" - self.Depth += 1 - self.Expr.append("and") - self.parseNode(node) - self.Depth -= 1 - self.Expr.pop() - self.Rule += ")" - - def parseOr(self, node): - self.parseNew(False) - self.Rule += "(" - self.Depth += 1 - self.Expr.append("or") - self.parseNode(node) - self.Depth -= 1 - self.Expr.pop() - self.Rule += ")" - - def parseNot(self, node): - self.parseNew(False) - self.Rule += "not (" - self.Depth += 1 - self.Expr.append("or") - self.parseNode(node) - self.Depth -= 1 - self.Expr.pop() - self.Rule += ")" - class MenuEntry: "Wrapper for 'Menu Style' Desktop Entries" + + TYPE_USER = "User" + TYPE_SYSTEM = "System" + TYPE_BOTH = "Both" + def __init__(self, filename, dir="", prefix=""): # Create entry - self.DesktopEntry = DesktopEntry(os.path.join(dir,filename)) + self.DesktopEntry = DesktopEntry(os.path.join(dir, filename)) self.setAttributes(filename, dir, prefix) - # Can be one of Deleted/Hidden/Empty/NotShowIn/NoExec or True + # Can True, False DELETED, HIDDEN, EMPTY, NOT_SHOW_IN or NO_EXEC self.Show = True # Semi-Private @@ -442,7 +466,7 @@ class MenuEntry: def save(self): """Save any changes to the desktop entry.""" - if self.DesktopEntry.tainted == True: + if self.DesktopEntry.tainted: self.DesktopEntry.write() def getDir(self): @@ -451,56 +475,55 @@ class MenuEntry: def getType(self): """Return the type of MenuEntry, System/User/Both""" - if xdg.Config.root_mode == False: + if not xdg.Config.root_mode: if self.Original: - return "Both" + return self.TYPE_BOTH elif xdg_data_dirs[0] in self.DesktopEntry.filename: - return "User" + return self.TYPE_USER else: - return "System" + return self.TYPE_SYSTEM else: - return "User" + return self.TYPE_USER def setAttributes(self, filename, dir="", prefix=""): self.Filename = filename self.Prefix = prefix - self.DesktopFileID = os.path.join(prefix,filename).replace("/", "-") + self.DesktopFileID = os.path.join(prefix, filename).replace("/", "-") if not os.path.isabs(self.DesktopEntry.filename): self.__setFilename() def updateAttributes(self): - if self.getType() == "System": + if self.getType() == self.TYPE_SYSTEM: self.Original = MenuEntry(self.Filename, self.getDir(), self.Prefix) self.__setFilename() def __setFilename(self): - if xdg.Config.root_mode == False: + if not xdg.Config.root_mode: path = xdg_data_dirs[0] else: - path= xdg_data_dirs[1] + path = xdg_data_dirs[1] if self.DesktopEntry.getType() == "Application": - dir = os.path.join(path, "applications") + dir_ = os.path.join(path, "applications") else: - dir = os.path.join(path, "desktop-directories") + dir_ = os.path.join(path, "desktop-directories") - self.DesktopEntry.filename = os.path.join(dir, self.Filename) + self.DesktopEntry.filename = os.path.join(dir_, self.Filename) def __cmp__(self, other): return locale.strcoll(self.DesktopEntry.getName(), other.DesktopEntry.getName()) - + def _key(self): """Key function for locale-aware sorting.""" return _strxfrm(self.DesktopEntry.getName()) - + def __lt__(self, other): try: other = other._key() except AttributeError: pass return self._key() < other - def __eq__(self, other): if self.DesktopFileID == str(other): @@ -530,551 +553,515 @@ class Header: return self.Name -tmp = {} - -def __getFileName(filename): - dirs = xdg_config_dirs[:] - if xdg.Config.root_mode == True: - dirs.pop(0) - - for dir in dirs: - menuname = os.path.join (dir, "menus" , filename) - if os.path.isdir(dir) and os.path.isfile(menuname): - return menuname - -def parse(filename=None): - """Load an applications.menu file. - - filename : str, optional - The default is ``$XDG_CONFIG_DIRS/menus/${XDG_MENU_PREFIX}applications.menu``. - """ - # convert to absolute path - if filename and not os.path.isabs(filename): - filename = __getFileName(filename) - - # use default if no filename given - if not filename: - candidate = os.environ.get('XDG_MENU_PREFIX', '') + "applications.menu" - filename = __getFileName(candidate) - - if not filename: - raise ParsingError('File not found', "/etc/xdg/menus/%s" % candidate) - - # check if it is a .menu file - if not os.path.splitext(filename)[1] == ".menu": - raise ParsingError('Not a .menu file', filename) - - # create xml parser - try: - doc = xml.dom.minidom.parse(filename) - except xml.parsers.expat.ExpatError: - raise ParsingError('Not a valid .menu file', filename) - - # parse menufile - tmp["Root"] = "" - tmp["mergeFiles"] = [] - tmp["DirectoryDirs"] = [] - tmp["cache"] = MenuEntryCache() - - __parse(doc, filename, tmp["Root"]) - __parsemove(tmp["Root"]) - __postparse(tmp["Root"]) - - tmp["Root"].Doc = doc - tmp["Root"].Filename = filename - - # generate the menu - __genmenuNotOnlyAllocated(tmp["Root"]) - __genmenuOnlyAllocated(tmp["Root"]) - - # and finally sort - sort(tmp["Root"]) - - return tmp["Root"] +TYPE_DIR, TYPE_FILE = 0, 1 -def __parse(node, filename, parent=None): - for child in node.childNodes: - if child.nodeType == ELEMENT_NODE: - if child.tagName == 'Menu': - __parseMenu(child, filename, parent) - elif child.tagName == 'AppDir': - try: - __parseAppDir(child.childNodes[0].nodeValue, filename, parent) - except IndexError: - raise ValidationError('AppDir cannot be empty', filename) - elif child.tagName == 'DefaultAppDirs': - __parseDefaultAppDir(filename, parent) - elif child.tagName == 'DirectoryDir': - try: - __parseDirectoryDir(child.childNodes[0].nodeValue, filename, parent) - except IndexError: - raise ValidationError('DirectoryDir cannot be empty', filename) - elif child.tagName == 'DefaultDirectoryDirs': - __parseDefaultDirectoryDir(filename, parent) - elif child.tagName == 'Name' : - try: - parent.Name = child.childNodes[0].nodeValue - except IndexError: - raise ValidationError('Name cannot be empty', filename) - elif child.tagName == 'Directory' : - try: - parent.Directories.append(child.childNodes[0].nodeValue) - except IndexError: - raise ValidationError('Directory cannot be empty', filename) - elif child.tagName == 'OnlyUnallocated': - parent.OnlyUnallocated = True - elif child.tagName == 'NotOnlyUnallocated': - parent.OnlyUnallocated = False - elif child.tagName == 'Deleted': - parent.Deleted = True - elif child.tagName == 'NotDeleted': - parent.Deleted = False - elif child.tagName == 'Include' or child.tagName == 'Exclude': - parent.Rules.append(Rule(child.tagName, child)) - elif child.tagName == 'MergeFile': - try: - if child.getAttribute("type") == "parent": - __parseMergeFile("applications.menu", child, filename, parent) - else: - __parseMergeFile(child.childNodes[0].nodeValue, child, filename, parent) - except IndexError: - raise ValidationError('MergeFile cannot be empty', filename) - elif child.tagName == 'MergeDir': - try: - __parseMergeDir(child.childNodes[0].nodeValue, child, filename, parent) - except IndexError: - raise ValidationError('MergeDir cannot be empty', filename) - elif child.tagName == 'DefaultMergeDirs': - __parseDefaultMergeDirs(child, filename, parent) - elif child.tagName == 'Move': - parent.Moves.append(Move(child)) - elif child.tagName == 'Layout': - if len(child.childNodes) > 1: - parent.Layout = Layout(child) - elif child.tagName == 'DefaultLayout': - if len(child.childNodes) > 1: - parent.DefaultLayout = Layout(child) - elif child.tagName == 'LegacyDir': - try: - __parseLegacyDir(child.childNodes[0].nodeValue, child.getAttribute("prefix"), filename, parent) - except IndexError: - raise ValidationError('LegacyDir cannot be empty', filename) - elif child.tagName == 'KDELegacyDirs': - __parseKDELegacyDirs(filename, parent) - -def __parsemove(menu): - for submenu in menu.Submenus: - __parsemove(submenu) - - # parse move operations - for move in menu.Moves: - move_from_menu = menu.getMenu(move.Old) - if move_from_menu: - move_to_menu = menu.getMenu(move.New) - - menus = move.New.split("/") - oldparent = None - while len(menus) > 0: - if not oldparent: - oldparent = menu - newmenu = oldparent.getMenu(menus[0]) - if not newmenu: - newmenu = Menu() - newmenu.Name = menus[0] - if len(menus) > 1: - newmenu.NotInXml = True - oldparent.addSubmenu(newmenu) - oldparent = newmenu - menus.pop(0) - - newmenu += move_from_menu - move_from_menu.Parent.Submenus.remove(move_from_menu) - -def __postparse(menu): - # unallocated / deleted - if menu.Deleted == "notset": - menu.Deleted = False - if menu.OnlyUnallocated == "notset": - menu.OnlyUnallocated = False - - # Layout Tags - if not menu.Layout or not menu.DefaultLayout: - if menu.DefaultLayout: - menu.Layout = menu.DefaultLayout - elif menu.Layout: - if menu.Depth > 0: - menu.DefaultLayout = menu.Parent.DefaultLayout - else: - menu.DefaultLayout = Layout() - else: - if menu.Depth > 0: - menu.Layout = menu.Parent.DefaultLayout - menu.DefaultLayout = menu.Parent.DefaultLayout - else: - menu.Layout = Layout() - menu.DefaultLayout = Layout() - - # add parent's app/directory dirs - if menu.Depth > 0: - menu.AppDirs = menu.Parent.AppDirs + menu.AppDirs - menu.DirectoryDirs = menu.Parent.DirectoryDirs + menu.DirectoryDirs - - # remove duplicates - menu.Directories = __removeDuplicates(menu.Directories) - menu.DirectoryDirs = __removeDuplicates(menu.DirectoryDirs) - menu.AppDirs = __removeDuplicates(menu.AppDirs) - - # go recursive through all menus - for submenu in menu.Submenus: - __postparse(submenu) - - # reverse so handling is easier - menu.Directories.reverse() - menu.DirectoryDirs.reverse() - menu.AppDirs.reverse() - - # get the valid .directory file out of the list - for directory in menu.Directories: - for dir in menu.DirectoryDirs: - if os.path.isfile(os.path.join(dir, directory)): - menuentry = MenuEntry(directory, dir) - if not menu.Directory: - menu.Directory = menuentry - elif menuentry.getType() == "System": - if menu.Directory.getType() == "User": - menu.Directory.Original = menuentry - if menu.Directory: - break - - -# Menu parsing stuff -def __parseMenu(child, filename, parent): - m = Menu() - __parse(child, filename, m) - if parent: - parent.addSubmenu(m) - else: - tmp["Root"] = m - -# helper function -def __check(value, filename, type): +def _check_file_path(value, filename, type): path = os.path.dirname(filename) - if not os.path.isabs(value): value = os.path.join(path, value) - value = os.path.abspath(value) - - if type == "dir" and os.path.exists(value) and os.path.isdir(value): - return value - elif type == "file" and os.path.exists(value) and os.path.isfile(value): - return value - else: + if not os.path.exists(value): return False - -# App/Directory Dir Stuff -def __parseAppDir(value, filename, parent): - value = __check(value, filename, "dir") - if value: - parent.AppDirs.append(value) - -def __parseDefaultAppDir(filename, parent): - for dir in reversed(xdg_data_dirs): - __parseAppDir(os.path.join(dir, "applications"), filename, parent) - -def __parseDirectoryDir(value, filename, parent): - value = __check(value, filename, "dir") - if value: - parent.DirectoryDirs.append(value) - -def __parseDefaultDirectoryDir(filename, parent): - for dir in reversed(xdg_data_dirs): - __parseDirectoryDir(os.path.join(dir, "desktop-directories"), filename, parent) - -# Merge Stuff -def __parseMergeFile(value, child, filename, parent): - if child.getAttribute("type") == "parent": - for dir in xdg_config_dirs: - rel_file = filename.replace(dir, "").strip("/") - if rel_file != filename: - for p in xdg_config_dirs: - if dir == p: - continue - if os.path.isfile(os.path.join(p,rel_file)): - __mergeFile(os.path.join(p,rel_file),child,parent) - break - else: - value = __check(value, filename, "file") - if value: - __mergeFile(value, child, parent) - -def __parseMergeDir(value, child, filename, parent): - value = __check(value, filename, "dir") - if value: - for item in os.listdir(value): - try: - if os.path.splitext(item)[1] == ".menu": - __mergeFile(os.path.join(value, item), child, parent) - except UnicodeDecodeError: - continue - -def __parseDefaultMergeDirs(child, filename, parent): - basename = os.path.splitext(os.path.basename(filename))[0] - for dir in reversed(xdg_config_dirs): - __parseMergeDir(os.path.join(dir, "menus", basename + "-merged"), child, filename, parent) - -def __mergeFile(filename, child, parent): - # check for infinite loops - if filename in tmp["mergeFiles"]: - if debug: - raise ParsingError('Infinite MergeFile loop detected', filename) - else: - return - - tmp["mergeFiles"].append(filename) - - # load file - try: - doc = xml.dom.minidom.parse(filename) - except IOError: - if debug: - raise ParsingError('File not found', filename) - else: - return - except xml.parsers.expat.ExpatError: - if debug: - raise ParsingError('Not a valid .menu file', filename) - else: - return - - # append file - for child in doc.childNodes: - if child.nodeType == ELEMENT_NODE: - __parse(child,filename,parent) - break - -# Legacy Dir Stuff -def __parseLegacyDir(dir, prefix, filename, parent): - m = __mergeLegacyDir(dir,prefix,filename,parent) - if m: - parent += m - -def __mergeLegacyDir(dir, prefix, filename, parent): - dir = __check(dir,filename,"dir") - if dir and dir not in tmp["DirectoryDirs"]: - tmp["DirectoryDirs"].append(dir) - - m = Menu() - m.AppDirs.append(dir) - m.DirectoryDirs.append(dir) - m.Name = os.path.basename(dir) - m.NotInXml = True - - for item in os.listdir(dir): - try: - if item == ".directory": - m.Directories.append(item) - elif os.path.isdir(os.path.join(dir,item)): - m.addSubmenu(__mergeLegacyDir(os.path.join(dir,item), prefix, filename, parent)) - except UnicodeDecodeError: - continue - - tmp["cache"].addMenuEntries([dir],prefix, True) - menuentries = tmp["cache"].getMenuEntries([dir], False) - - for menuentry in menuentries: - categories = menuentry.Categories - if len(categories) == 0: - r = Rule("Include") - r.parseFilename(menuentry.DesktopFileID) - m.Rules.append(r) - if not dir in parent.AppDirs: - categories.append("Legacy") - menuentry.Categories = categories - - return m - -def __parseKDELegacyDirs(filename, parent): - try: - proc = subprocess.Popen(['kde-config', '--path', 'apps'], - stdout=subprocess.PIPE, universal_newlines=True) - output = proc.communicate()[0].splitlines() - except OSError: - # If kde-config doesn't exist, ignore this. - return - - try: - for dir in output[0].split(":"): - __parseLegacyDir(dir,"kde", filename, parent) - except IndexError: - pass - -# remove duplicate entries from a list -def __removeDuplicates(list): - set = {} - list.reverse() - list = [set.setdefault(e,e) for e in list if e not in set] - list.reverse() - return list - -# Finally generate the menu -def __genmenuNotOnlyAllocated(menu): - for submenu in menu.Submenus: - __genmenuNotOnlyAllocated(submenu) - - if menu.OnlyUnallocated == False: - tmp["cache"].addMenuEntries(menu.AppDirs) - menuentries = [] - for rule in menu.Rules: - menuentries = rule.do(tmp["cache"].getMenuEntries(menu.AppDirs), rule.Type, 1) - for menuentry in menuentries: - if menuentry.Add == True: - menuentry.Parents.append(menu) - menuentry.Add = False - menuentry.Allocated = True - menu.MenuEntries.append(menuentry) - -def __genmenuOnlyAllocated(menu): - for submenu in menu.Submenus: - __genmenuOnlyAllocated(submenu) - - if menu.OnlyUnallocated == True: - tmp["cache"].addMenuEntries(menu.AppDirs) - menuentries = [] - for rule in menu.Rules: - menuentries = rule.do(tmp["cache"].getMenuEntries(menu.AppDirs), rule.Type, 2) - for menuentry in menuentries: - if menuentry.Add == True: - menuentry.Parents.append(menu) - # menuentry.Add = False - # menuentry.Allocated = True - menu.MenuEntries.append(menuentry) - -# And sorting ... -def sort(menu): - menu.Entries = [] - menu.Visible = 0 - - for submenu in menu.Submenus: - sort(submenu) - - tmp_s = [] - tmp_e = [] - - for order in menu.Layout.order: - if order[0] == "Filename": - tmp_e.append(order[1]) - elif order[0] == "Menuname": - tmp_s.append(order[1]) - - for order in menu.Layout.order: - if order[0] == "Separator": - separator = Separator(menu) - if len(menu.Entries) > 0 and isinstance(menu.Entries[-1], Separator): - separator.Show = False - menu.Entries.append(separator) - elif order[0] == "Filename": - menuentry = menu.getMenuEntry(order[1]) - if menuentry: - menu.Entries.append(menuentry) - elif order[0] == "Menuname": - submenu = menu.getMenu(order[1]) - if submenu: - __parse_inline(submenu, menu) - elif order[0] == "Merge": - if order[1] == "files" or order[1] == "all": - menu.MenuEntries.sort() - for menuentry in menu.MenuEntries: - if menuentry not in tmp_e: - menu.Entries.append(menuentry) - elif order[1] == "menus" or order[1] == "all": - menu.Submenus.sort() - for submenu in menu.Submenus: - if submenu.Name not in tmp_s: - __parse_inline(submenu, menu) - - # getHidden / NoDisplay / OnlyShowIn / NotOnlyShowIn / Deleted / NoExec - for entry in menu.Entries: - entry.Show = True - menu.Visible += 1 - if isinstance(entry, Menu): - if entry.Deleted == True: - entry.Show = "Deleted" - menu.Visible -= 1 - elif isinstance(entry.Directory, MenuEntry): - if entry.Directory.DesktopEntry.getNoDisplay() == True: - entry.Show = "NoDisplay" - menu.Visible -= 1 - elif entry.Directory.DesktopEntry.getHidden() == True: - entry.Show = "Hidden" - menu.Visible -= 1 - elif isinstance(entry, MenuEntry): - if entry.DesktopEntry.getNoDisplay() == True: - entry.Show = "NoDisplay" - menu.Visible -= 1 - elif entry.DesktopEntry.getHidden() == True: - entry.Show = "Hidden" - menu.Visible -= 1 - elif entry.DesktopEntry.getTryExec() and not __try_exec(entry.DesktopEntry.getTryExec()): - entry.Show = "NoExec" - menu.Visible -= 1 - elif xdg.Config.windowmanager: - if ( entry.DesktopEntry.getOnlyShowIn() != [] and xdg.Config.windowmanager not in entry.DesktopEntry.getOnlyShowIn() ) \ - or xdg.Config.windowmanager in entry.DesktopEntry.getNotShowIn(): - entry.Show = "NotShowIn" - menu.Visible -= 1 - elif isinstance(entry,Separator): - menu.Visible -= 1 - - # remove separators at the beginning and at the end - if len(menu.Entries) > 0: - if isinstance(menu.Entries[0], Separator): - menu.Entries[0].Show = False - if len(menu.Entries) > 1: - if isinstance(menu.Entries[-1], Separator): - menu.Entries[-1].Show = False - - # show_empty tag - for entry in menu.Entries[:]: - if isinstance(entry, Menu) and entry.Layout.show_empty == "false" and entry.Visible == 0: - entry.Show = "Empty" - menu.Visible -= 1 - if entry.NotInXml == True: - menu.Entries.remove(entry) - -def __try_exec(executable): - paths = os.environ['PATH'].split(os.pathsep) - if not os.path.isfile(executable): - for p in paths: - f = os.path.join(p, executable) - if os.path.isfile(f): - if os.access(f, os.X_OK): - return True - else: - if os.access(executable, os.X_OK): - return True + if type == TYPE_DIR and os.path.isdir(value): + return value + if type == TYPE_FILE and os.path.isfile(value): + return value return False -# inline tags -def __parse_inline(submenu, menu): - if submenu.Layout.inline == "true": - if len(submenu.Entries) == 1 and submenu.Layout.inline_alias == "true": - menuentry = submenu.Entries[0] - menuentry.DesktopEntry.set("Name", submenu.getName(), locale = True) - menuentry.DesktopEntry.set("GenericName", submenu.getGenericName(), locale = True) - menuentry.DesktopEntry.set("Comment", submenu.getComment(), locale = True) - menu.Entries.append(menuentry) - elif len(submenu.Entries) <= submenu.Layout.inline_limit or submenu.Layout.inline_limit == 0: - if submenu.Layout.inline_header == "true": - header = Header(submenu.getName(), submenu.getGenericName(), submenu.getComment()) - menu.Entries.append(header) - for entry in submenu.Entries: - menu.Entries.append(entry) + +def _get_menu_file_path(filename): + dirs = list(xdg_config_dirs) + if xdg.Config.root_mode is True: + dirs.pop(0) + for d in dirs: + menuname = os.path.join(d, "menus", filename) + if os.path.isfile(menuname): + return menuname + + +def _to_bool(value): + if isinstance(value, bool): + return value + return value.lower() == "true" + + +# remove duplicate entries from a list +def _dedupe(_list): + _set = {} + _list.reverse() + _list = [_set.setdefault(e, e) for e in _list if e not in _set] + _list.reverse() + return _list + + +class XMLMenuBuilder(object): + + def __init__(self, debug=False): + self.debug = debug + + def parse(self, filename=None): + """Load an applications.menu file. + + filename : str, optional + The default is ``$XDG_CONFIG_DIRS/menus/${XDG_MENU_PREFIX}applications.menu``. + """ + # convert to absolute path + if filename and not os.path.isabs(filename): + filename = _get_menu_file_path(filename) + # use default if no filename given + if not filename: + candidate = os.environ.get('XDG_MENU_PREFIX', '') + "applications.menu" + filename = _get_menu_file_path(candidate) + if not filename: + raise ParsingError('File not found', "/etc/xdg/menus/%s" % candidate) + # check if it is a .menu file + if not filename.endswith(".menu"): + raise ParsingError('Not a .menu file', filename) + # create xml parser + try: + tree = etree.parse(filename) + except: + raise ParsingError('Not a valid .menu file', filename) + + # parse menufile + self._merged_files = set() + self._directory_dirs = set() + self.cache = MenuEntryCache() + + menu = self.parse_menu(tree.getroot(), filename) + menu.tree = tree + menu.filename = filename + + self.handle_moves(menu) + self.post_parse(menu) + + # generate the menu + self.generate_not_only_allocated(menu) + self.generate_only_allocated(menu) + + # and finally sort + menu.sort() + + return menu + + def parse_menu(self, node, filename): + menu = Menu() + self.parse_node(node, filename, menu) + return menu + + def parse_node(self, node, filename, parent=None): + num_children = len(node) + for child in node: + tag, text = child.tag, child.text + text = text.strip() if text else None + if tag == 'Menu': + menu = self.parse_menu(child, filename) + parent.addSubmenu(menu) + elif tag == 'AppDir' and text: + self.parse_app_dir(text, filename, parent) + elif tag == 'DefaultAppDirs': + self.parse_default_app_dir(filename, parent) + elif tag == 'DirectoryDir' and text: + self.parse_directory_dir(text, filename, parent) + elif tag == 'DefaultDirectoryDirs': + self.parse_default_directory_dir(filename, parent) + elif tag == 'Name' and text: + parent.Name = text + elif tag == 'Directory' and text: + parent.Directories.append(text) + elif tag == 'OnlyUnallocated': + parent.OnlyUnallocated = True + elif tag == 'NotOnlyUnallocated': + parent.OnlyUnallocated = False + elif tag == 'Deleted': + parent.Deleted = True + elif tag == 'NotDeleted': + parent.Deleted = False + elif tag == 'Include' or tag == 'Exclude': + parent.Rules.append(self.parse_rule(child)) + elif tag == 'MergeFile': + if child.attrib.get("type", None) == "parent": + self.parse_merge_file("applications.menu", child, filename, parent) + elif text: + self.parse_merge_file(text, child, filename, parent) + elif tag == 'MergeDir' and text: + self.parse_merge_dir(text, child, filename, parent) + elif tag == 'DefaultMergeDirs': + self.parse_default_merge_dirs(child, filename, parent) + elif tag == 'Move': + parent.Moves.append(self.parse_move(child)) + elif tag == 'Layout': + if num_children > 1: + parent.Layout = self.parse_layout(child) + elif tag == 'DefaultLayout': + if num_children > 1: + parent.DefaultLayout = self.parse_layout(child) + elif tag == 'LegacyDir' and text: + self.parse_legacy_dir(text, child.attrib.get("prefix", ""), filename, parent) + elif tag == 'KDELegacyDirs': + self.parse_kde_legacy_dirs(filename, parent) + + def parse_layout(self, node): + layout = Layout( + show_empty=_to_bool(node.attrib.get("show_empty", False)), + inline=_to_bool(node.attrib.get("inline", False)), + inline_limit=int(node.attrib.get("inline_limit", 4)), + inline_header=_to_bool(node.attrib.get("inline_header", True)), + inline_alias=_to_bool(node.attrib.get("inline_alias", False)) + ) + for child in node: + tag, text = child.tag, child.text + text = text.strip() if text else None + if tag == "Menuname" and text: + layout.order.append([ + "Menuname", + text, + _to_bool(child.attrib.get("show_empty", False)), + _to_bool(child.attrib.get("inline", False)), + int(child.attrib.get("inline_limit", 4)), + _to_bool(child.attrib.get("inline_header", True)), + _to_bool(child.attrib.get("inline_alias", False)) + ]) + elif tag == "Separator": + layout.order.append(['Separator']) + elif tag == "Filename" and text: + layout.order.append(["Filename", text]) + elif tag == "Merge": + layout.order.append([ + "Merge", + child.attrib.get("type", "all") + ]) + return layout + + def parse_move(self, node): + old, new = "", "" + for child in node: + tag, text = child.tag, child.text + text = text.strip() if text else None + if tag == "Old" and text: + old = text + elif tag == "New" and text: + new = text + return Move(old, new) + + # ---------- parsing + + def parse_rule(self, node): + type = Rule.TYPE_INCLUDE if node.tag == 'Include' else Rule.TYPE_EXCLUDE + tree = ast.Expression(lineno=1, col_offset=0) + expr = self.parse_bool_op(node, ast.Or()) + if expr: + tree.body = expr else: - menu.Entries.append(submenu) - else: - menu.Entries.append(submenu) + tree.body = ast.Name('False', ast.Load()) + ast.fix_missing_locations(tree) + return Rule(type, tree) + + def parse_bool_op(self, node, operator): + values = [] + for child in node: + rule = self.parse_rule_node(child) + if rule: + values.append(rule) + num_values = len(values) + if num_values > 1: + return ast.BoolOp(operator, values) + elif num_values == 1: + return values[0] + return None + + def parse_rule_node(self, node): + tag = node.tag + if tag == 'Or': + return self.parse_bool_op(node, ast.Or()) + elif tag == 'And': + return self.parse_bool_op(node, ast.And()) + elif tag == 'Not': + expr = self.parse_bool_op(node, ast.Or()) + return ast.UnaryOp(ast.Not(), expr) if expr else None + elif tag == 'All': + return ast.Name('True', ast.Load()) + elif tag == 'Category': + category = node.text + return ast.Compare( + left=ast.Str(category), + ops=[ast.In()], + comparators=[ast.Attribute( + value=ast.Name(id='menuentry', ctx=ast.Load()), + attr='Categories', + ctx=ast.Load() + )] + ) + elif tag == 'Filename': + filename = node.text + return ast.Compare( + left=ast.Str(filename), + ops=[ast.Eq()], + comparators=[ast.Attribute( + value=ast.Name(id='menuentry', ctx=ast.Load()), + attr='DesktopFileID', + ctx=ast.Load() + )] + ) + + # ---------- App/Directory Dir Stuff + + def parse_app_dir(self, value, filename, parent): + value = _check_file_path(value, filename, TYPE_DIR) + if value: + parent.AppDirs.append(value) + + def parse_default_app_dir(self, filename, parent): + for d in reversed(xdg_data_dirs): + self.parse_app_dir(os.path.join(d, "applications"), filename, parent) + + def parse_directory_dir(self, value, filename, parent): + value = _check_file_path(value, filename, TYPE_DIR) + if value: + parent.DirectoryDirs.append(value) + + def parse_default_directory_dir(self, filename, parent): + for d in reversed(xdg_data_dirs): + self.parse_directory_dir(os.path.join(d, "desktop-directories"), filename, parent) + + # ---------- Merge Stuff + + def parse_merge_file(self, value, child, filename, parent): + if child.attrib.get("type", None) == "parent": + for d in xdg_config_dirs: + rel_file = filename.replace(d, "").strip("/") + if rel_file != filename: + for p in xdg_config_dirs: + if d == p: + continue + if os.path.isfile(os.path.join(p, rel_file)): + self.merge_file(os.path.join(p, rel_file), child, parent) + break + else: + value = _check_file_path(value, filename, TYPE_FILE) + if value: + self.merge_file(value, child, parent) + + def parse_merge_dir(self, value, child, filename, parent): + value = _check_file_path(value, filename, TYPE_DIR) + if value: + for item in os.listdir(value): + try: + if item.endswith(".menu"): + self.merge_file(os.path.join(value, item), child, parent) + except UnicodeDecodeError: + continue + + def parse_default_merge_dirs(self, child, filename, parent): + basename = os.path.splitext(os.path.basename(filename))[0] + for d in reversed(xdg_config_dirs): + self.parse_merge_dir(os.path.join(d, "menus", basename + "-merged"), child, filename, parent) + + def merge_file(self, filename, child, parent): + # check for infinite loops + if filename in self._merged_files: + if self.debug: + raise ParsingError('Infinite MergeFile loop detected', filename) + else: + return + self._merged_files.add(filename) + # load file + try: + tree = etree.parse(filename) + except IOError: + if self.debug: + raise ParsingError('File not found', filename) + else: + return + except: + if self.debug: + raise ParsingError('Not a valid .menu file', filename) + else: + return + root = tree.getroot() + self.parse_node(root, filename, parent) + + # ---------- Legacy Dir Stuff + + def parse_legacy_dir(self, dir_, prefix, filename, parent): + m = self.merge_legacy_dir(dir_, prefix, filename, parent) + if m: + parent += m + + def merge_legacy_dir(self, dir_, prefix, filename, parent): + dir_ = _check_file_path(dir_, filename, TYPE_DIR) + if dir_ and dir_ not in self._directory_dirs: + self._directory_dirs.add(dir_) + m = Menu() + m.AppDirs.append(dir_) + m.DirectoryDirs.append(dir_) + m.Name = os.path.basename(dir_) + m.NotInXml = True + + for item in os.listdir(dir_): + try: + if item == ".directory": + m.Directories.append(item) + elif os.path.isdir(os.path.join(dir_, item)): + m.addSubmenu(self.merge_legacy_dir( + os.path.join(dir_, item), + prefix, + filename, + parent + )) + except UnicodeDecodeError: + continue + + self.cache.add_menu_entries([dir_], prefix, True) + menuentries = self.cache.get_menu_entries([dir_], False) + + for menuentry in menuentries: + categories = menuentry.Categories + if len(categories) == 0: + r = Rule.fromFilename(Rule.TYPE_INCLUDE, menuentry.DesktopFileID) + m.Rules.append(r) + if not dir_ in parent.AppDirs: + categories.append("Legacy") + menuentry.Categories = categories + + return m + + def parse_kde_legacy_dirs(self, filename, parent): + try: + proc = subprocess.Popen( + ['kde-config', '--path', 'apps'], + stdout=subprocess.PIPE, + universal_newlines=True + ) + output = proc.communicate()[0].splitlines() + except OSError: + # If kde-config doesn't exist, ignore this. + return + try: + for dir_ in output[0].split(":"): + self.parse_legacy_dir(dir_, "kde", filename, parent) + except IndexError: + pass + + def post_parse(self, menu): + # unallocated / deleted + if menu.Deleted is None: + menu.Deleted = False + if menu.OnlyUnallocated is None: + menu.OnlyUnallocated = False + + # Layout Tags + if not menu.Layout or not menu.DefaultLayout: + if menu.DefaultLayout: + menu.Layout = menu.DefaultLayout + elif menu.Layout: + if menu.Depth > 0: + menu.DefaultLayout = menu.Parent.DefaultLayout + else: + menu.DefaultLayout = Layout() + else: + if menu.Depth > 0: + menu.Layout = menu.Parent.DefaultLayout + menu.DefaultLayout = menu.Parent.DefaultLayout + else: + menu.Layout = Layout() + menu.DefaultLayout = Layout() + + # add parent's app/directory dirs + if menu.Depth > 0: + menu.AppDirs = menu.Parent.AppDirs + menu.AppDirs + menu.DirectoryDirs = menu.Parent.DirectoryDirs + menu.DirectoryDirs + + # remove duplicates + menu.Directories = _dedupe(menu.Directories) + menu.DirectoryDirs = _dedupe(menu.DirectoryDirs) + menu.AppDirs = _dedupe(menu.AppDirs) + + # go recursive through all menus + for submenu in menu.Submenus: + self.post_parse(submenu) + + # reverse so handling is easier + menu.Directories.reverse() + menu.DirectoryDirs.reverse() + menu.AppDirs.reverse() + + # get the valid .directory file out of the list + for directory in menu.Directories: + for dir in menu.DirectoryDirs: + if os.path.isfile(os.path.join(dir, directory)): + menuentry = MenuEntry(directory, dir) + if not menu.Directory: + menu.Directory = menuentry + elif menuentry.Type == MenuEntry.TYPE_SYSTEM: + if menu.Directory.Type == MenuEntry.TYPE_USER: + menu.Directory.Original = menuentry + if menu.Directory: + break + + # Finally generate the menu + def generate_not_only_allocated(self, menu): + for submenu in menu.Submenus: + self.generate_not_only_allocated(submenu) + + if menu.OnlyUnallocated is False: + self.cache.add_menu_entries(menu.AppDirs) + menuentries = [] + for rule in menu.Rules: + menuentries = rule.apply(self.cache.get_menu_entries(menu.AppDirs), 1) + + for menuentry in menuentries: + if menuentry.Add is True: + menuentry.Parents.append(menu) + menuentry.Add = False + menuentry.Allocated = True + menu.MenuEntries.append(menuentry) + + def generate_only_allocated(self, menu): + for submenu in menu.Submenus: + self.generate_only_allocated(submenu) + + if menu.OnlyUnallocated is True: + self.cache.add_menu_entries(menu.AppDirs) + menuentries = [] + for rule in menu.Rules: + menuentries = rule.apply(self.cache.get_menu_entries(menu.AppDirs), 2) + for menuentry in menuentries: + if menuentry.Add is True: + menuentry.Parents.append(menu) + # menuentry.Add = False + # menuentry.Allocated = True + menu.MenuEntries.append(menuentry) + + def handle_moves(self, menu): + for submenu in menu.Submenus: + self.handle_moves(submenu) + # parse move operations + for move in menu.Moves: + move_from_menu = menu.getMenu(move.Old) + if move_from_menu: + # FIXME: this is assigned, but never used... + move_to_menu = menu.getMenu(move.New) + + menus = move.New.split("/") + oldparent = None + while len(menus) > 0: + if not oldparent: + oldparent = menu + newmenu = oldparent.getMenu(menus[0]) + if not newmenu: + newmenu = Menu() + newmenu.Name = menus[0] + if len(menus) > 1: + newmenu.NotInXml = True + oldparent.addSubmenu(newmenu) + oldparent = newmenu + menus.pop(0) + + newmenu += move_from_menu + move_from_menu.Parent.Submenus.remove(move_from_menu) + class MenuEntryCache: "Class to cache Desktop Entries" @@ -1083,32 +1070,32 @@ class MenuEntryCache: self.cacheEntries['legacy'] = [] self.cache = {} - def addMenuEntries(self, dirs, prefix="", legacy=False): - for dir in dirs: - if not dir in self.cacheEntries: - self.cacheEntries[dir] = [] - self.__addFiles(dir, "", prefix, legacy) + def add_menu_entries(self, dirs, prefix="", legacy=False): + for dir_ in dirs: + if not dir_ in self.cacheEntries: + self.cacheEntries[dir_] = [] + self.__addFiles(dir_, "", prefix, legacy) - def __addFiles(self, dir, subdir, prefix, legacy): - for item in os.listdir(os.path.join(dir,subdir)): - if os.path.splitext(item)[1] == ".desktop": + def __addFiles(self, dir_, subdir, prefix, legacy): + for item in os.listdir(os.path.join(dir_, subdir)): + if item.endswith(".desktop"): try: - menuentry = MenuEntry(os.path.join(subdir,item), dir, prefix) + menuentry = MenuEntry(os.path.join(subdir, item), dir_, prefix) except ParsingError: continue - self.cacheEntries[dir].append(menuentry) - if legacy == True: + self.cacheEntries[dir_].append(menuentry) + if legacy: self.cacheEntries['legacy'].append(menuentry) - elif os.path.isdir(os.path.join(dir,subdir,item)) and legacy == False: - self.__addFiles(dir, os.path.join(subdir,item), prefix, legacy) + elif os.path.isdir(os.path.join(dir_, subdir, item)) and not legacy: + self.__addFiles(dir_, os.path.join(subdir, item), prefix, legacy) - def getMenuEntries(self, dirs, legacy=True): - list = [] - ids = [] + def get_menu_entries(self, dirs, legacy=True): + entries = [] + ids = set() # handle legacy items appdirs = dirs[:] - if legacy == True: + if legacy: appdirs.append("legacy") # cache the results again key = "".join(appdirs) @@ -1116,19 +1103,26 @@ class MenuEntryCache: return self.cache[key] except KeyError: pass - for dir in appdirs: - for menuentry in self.cacheEntries[dir]: + for dir_ in appdirs: + for menuentry in self.cacheEntries[dir_]: try: if menuentry.DesktopFileID not in ids: - ids.append(menuentry.DesktopFileID) - list.append(menuentry) - elif menuentry.getType() == "System": - # FIXME: This is only 99% correct, but still... - i = list.index(menuentry) - e = list[i] - if e.getType() == "User": - e.Original = menuentry + ids.add(menuentry.DesktopFileID) + entries.append(menuentry) + elif menuentry.getType() == MenuEntry.TYPE_SYSTEM: + # FIXME: This is only 99% correct, but still... + idx = entries.index(menuentry) + entry = entries[idx] + if entry.getType() == MenuEntry.TYPE_USER: + entry.Original = menuentry except UnicodeDecodeError: continue - self.cache[key] = list - return list + self.cache[key] = entries + return entries + + +def parse(filename=None, debug=False): + """Helper function. + Equivalent to calling xdg.Menu.XMLMenuBuilder().parse(filename) + """ + return XMLMenuBuilder(debug).parse(filename) diff --git a/libs/xdg/MenuEditor.py b/libs/xdg/MenuEditor.py index cc5ce54d..25b8e834 100644 --- a/libs/xdg/MenuEditor.py +++ b/libs/xdg/MenuEditor.py @@ -1,14 +1,14 @@ """ CLass to edit XDG Menus """ - -from xdg.Menu import * -from xdg.BaseDirectory import * -from xdg.Exceptions import * -from xdg.DesktopEntry import * -from xdg.Config import * - -import xml.dom.minidom import os -import re +try: + import xml.etree.cElementTree as etree +except ImportError: + import xml.etree.ElementTree as etree + +from xdg.Menu import Menu, MenuEntry, Layout, Separator, XMLMenuBuilder +from xdg.BaseDirectory import xdg_config_dirs, xdg_data_dirs +from xdg.Exceptions import ParsingError +from xdg.Config import setRootMode # XML-Cleanups: Move / Exclude # FIXME: proper reverte/delete @@ -20,28 +20,31 @@ import re # FIXME: Advanced MenuEditing Stuff: LegacyDir/MergeFile # Complex Rules/Deleted/OnlyAllocated/AppDirs/DirectoryDirs -class MenuEditor: + +class MenuEditor(object): + def __init__(self, menu=None, filename=None, root=False): self.menu = None self.filename = None - self.doc = None + self.tree = None + self.parser = XMLMenuBuilder() self.parse(menu, filename, root) # fix for creating two menus with the same name on the fly self.filenames = [] def parse(self, menu=None, filename=None, root=False): - if root == True: + if root: setRootMode(True) if isinstance(menu, Menu): self.menu = menu elif menu: - self.menu = parse(menu) + self.menu = self.parser.parse(menu) else: - self.menu = parse() + self.menu = self.parser.parse() - if root == True: + if root: self.filename = self.menu.Filename elif filename: self.filename = filename @@ -49,13 +52,21 @@ class MenuEditor: self.filename = os.path.join(xdg_config_dirs[0], "menus", os.path.split(self.menu.Filename)[1]) try: - self.doc = xml.dom.minidom.parse(self.filename) + self.tree = etree.parse(self.filename) except IOError: - self.doc = xml.dom.minidom.parseString('Applications'+self.menu.Filename+'') - except xml.parsers.expat.ExpatError: + root = etree.fromtring(""" + + + Applications + %s + +""" % self.menu.Filename) + self.tree = etree.ElementTree(root) + except ParsingError: raise ParsingError('Not a valid .menu file', self.filename) - self.__remove_whilespace_nodes(self.doc) + #FIXME: is this needed with etree ? + self.__remove_whitespace_nodes(self.tree) def save(self): self.__saveEntries(self.menu) @@ -67,7 +78,7 @@ class MenuEditor: self.__addEntry(parent, menuentry, after, before) - sort(self.menu) + self.menu.sort() return menuentry @@ -83,7 +94,7 @@ class MenuEditor: self.__addEntry(parent, menu, after, before) - sort(self.menu) + self.menu.sort() return menu @@ -92,7 +103,7 @@ class MenuEditor: self.__addEntry(parent, separator, after, before) - sort(self.menu) + self.menu.sort() return separator @@ -100,7 +111,7 @@ class MenuEditor: self.__deleteEntry(oldparent, menuentry, after, before) self.__addEntry(newparent, menuentry, after, before) - sort(self.menu) + self.menu.sort() return menuentry @@ -112,7 +123,7 @@ class MenuEditor: if oldparent.getPath(True) != newparent.getPath(True): self.__addXmlMove(root_menu, os.path.join(oldparent.getPath(True), menu.Name), os.path.join(newparent.getPath(True), menu.Name)) - sort(self.menu) + self.menu.sort() return menu @@ -120,14 +131,14 @@ class MenuEditor: self.__deleteEntry(parent, separator, after, before) self.__addEntry(parent, separator, after, before) - sort(self.menu) + self.menu.sort() return separator def copyMenuEntry(self, menuentry, oldparent, newparent, after=None, before=None): self.__addEntry(newparent, menuentry, after, before) - sort(self.menu) + self.menu.sort() return menuentry @@ -137,39 +148,39 @@ class MenuEditor: if name: if not deskentry.hasKey("Name"): deskentry.set("Name", name) - deskentry.set("Name", name, locale = True) + deskentry.set("Name", name, locale=True) if comment: if not deskentry.hasKey("Comment"): deskentry.set("Comment", comment) - deskentry.set("Comment", comment, locale = True) + deskentry.set("Comment", comment, locale=True) if genericname: - if not deskentry.hasKey("GnericNe"): + if not deskentry.hasKey("GenericName"): deskentry.set("GenericName", genericname) - deskentry.set("GenericName", genericname, locale = True) + deskentry.set("GenericName", genericname, locale=True) if command: deskentry.set("Exec", command) if icon: deskentry.set("Icon", icon) - if terminal == True: + if terminal: deskentry.set("Terminal", "true") - elif terminal == False: + elif not terminal: deskentry.set("Terminal", "false") - if nodisplay == True: + if nodisplay is True: deskentry.set("NoDisplay", "true") - elif nodisplay == False: + elif nodisplay is False: deskentry.set("NoDisplay", "false") - if hidden == True: + if hidden is True: deskentry.set("Hidden", "true") - elif hidden == False: + elif hidden is False: deskentry.set("Hidden", "false") menuentry.updateAttributes() if len(menuentry.Parents) > 0: - sort(self.menu) + self.menu.sort() return menuentry @@ -195,56 +206,58 @@ class MenuEditor: if name: if not deskentry.hasKey("Name"): deskentry.set("Name", name) - deskentry.set("Name", name, locale = True) + deskentry.set("Name", name, locale=True) if genericname: if not deskentry.hasKey("GenericName"): deskentry.set("GenericName", genericname) - deskentry.set("GenericName", genericname, locale = True) + deskentry.set("GenericName", genericname, locale=True) if comment: if not deskentry.hasKey("Comment"): deskentry.set("Comment", comment) - deskentry.set("Comment", comment, locale = True) + deskentry.set("Comment", comment, locale=True) if icon: deskentry.set("Icon", icon) - if nodisplay == True: + if nodisplay is True: deskentry.set("NoDisplay", "true") - elif nodisplay == False: + elif nodisplay is False: deskentry.set("NoDisplay", "false") - if hidden == True: + if hidden is True: deskentry.set("Hidden", "true") - elif hidden == False: + elif hidden is False: deskentry.set("Hidden", "false") menu.Directory.updateAttributes() if isinstance(menu.Parent, Menu): - sort(self.menu) + self.menu.sort() return menu def hideMenuEntry(self, menuentry): - self.editMenuEntry(menuentry, nodisplay = True) + self.editMenuEntry(menuentry, nodisplay=True) def unhideMenuEntry(self, menuentry): - self.editMenuEntry(menuentry, nodisplay = False, hidden = False) + self.editMenuEntry(menuentry, nodisplay=False, hidden=False) def hideMenu(self, menu): - self.editMenu(menu, nodisplay = True) + self.editMenu(menu, nodisplay=True) def unhideMenu(self, menu): - self.editMenu(menu, nodisplay = False, hidden = False) - xml_menu = self.__getXmlMenu(menu.getPath(True,True), False) - for node in self.__getXmlNodesByName(["Deleted", "NotDeleted"], xml_menu): - node.parentNode.removeChild(node) + self.editMenu(menu, nodisplay=False, hidden=False) + xml_menu = self.__getXmlMenu(menu.getPath(True, True), False) + deleted = xml_menu.findall('Deleted') + not_deleted = xml_menu.findall('NotDeleted') + for node in deleted + not_deleted: + xml_menu.remove(node) def deleteMenuEntry(self, menuentry): if self.getAction(menuentry) == "delete": self.__deleteFile(menuentry.DesktopEntry.filename) for parent in menuentry.Parents: self.__deleteEntry(parent, menuentry) - sort(self.menu) + self.menu.sort() return menuentry def revertMenuEntry(self, menuentry): @@ -257,7 +270,7 @@ class MenuEditor: index = parent.MenuEntries.index(menuentry) parent.MenuEntries[index] = menuentry.Original menuentry.Original.Parents.append(parent) - sort(self.menu) + self.menu.sort() return menuentry def deleteMenu(self, menu): @@ -265,21 +278,22 @@ class MenuEditor: self.__deleteFile(menu.Directory.DesktopEntry.filename) self.__deleteEntry(menu.Parent, menu) xml_menu = self.__getXmlMenu(menu.getPath(True, True)) - xml_menu.parentNode.removeChild(xml_menu) - sort(self.menu) + parent = self.__get_parent_node(xml_menu) + parent.remove(xml_menu) + self.menu.sort() return menu def revertMenu(self, menu): if self.getAction(menu) == "revert": self.__deleteFile(menu.Directory.DesktopEntry.filename) menu.Directory = menu.Directory.Original - sort(self.menu) + self.menu.sort() return menu def deleteSeparator(self, separator): self.__deleteEntry(separator.Parent, separator, after=True) - sort(self.menu) + self.menu.sort() return separator @@ -290,8 +304,9 @@ class MenuEditor: return "none" elif entry.Directory.getType() == "Both": return "revert" - elif entry.Directory.getType() == "User" \ - and (len(entry.Submenus) + len(entry.MenuEntries)) == 0: + elif entry.Directory.getType() == "User" and ( + len(entry.Submenus) + len(entry.MenuEntries) + ) == 0: return "delete" elif isinstance(entry, MenuEntry): @@ -318,9 +333,7 @@ class MenuEditor: def __saveMenu(self): if not os.path.isdir(os.path.dirname(self.filename)): os.makedirs(os.path.dirname(self.filename)) - fd = open(self.filename, 'w') - fd.write(re.sub("\n[\s]*([^\n<]*)\n[\s]*\n', ''))) - fd.close() + self.tree.write(self.filename, encoding='utf-8') def __getFileName(self, name, extension): postfix = 0 @@ -333,8 +346,9 @@ class MenuEditor: dir = "applications" elif extension == ".directory": dir = "desktop-directories" - if not filename in self.filenames and not \ - os.path.isfile(os.path.join(xdg_data_dirs[0], dir, filename)): + if not filename in self.filenames and not os.path.isfile( + os.path.join(xdg_data_dirs[0], dir, filename) + ): self.filenames.append(filename) break else: @@ -343,8 +357,11 @@ class MenuEditor: return filename def __getXmlMenu(self, path, create=True, element=None): + # FIXME: we should also return the menu's parent, + # to avoid looking for it later on + # @see Element.getiterator() if not element: - element = self.doc + element = self.tree if "/" in path: (name, path) = path.split("/", 1) @@ -353,17 +370,16 @@ class MenuEditor: path = "" found = None - for node in self.__getXmlNodesByName("Menu", element): - for child in self.__getXmlNodesByName("Name", node): - if child.childNodes[0].nodeValue == name: - if path: - found = self.__getXmlMenu(path, create, node) - else: - found = node - break + for node in element.findall("Menu"): + name_node = node.find('Name') + if name_node.text == name: + if path: + found = self.__getXmlMenu(path, create, node) + else: + found = node if found: break - if not found and create == True: + if not found and create: node = self.__addXmlMenuElement(element, name) if path: found = self.__getXmlMenu(path, create, node) @@ -373,58 +389,62 @@ class MenuEditor: return found def __addXmlMenuElement(self, element, name): - node = self.doc.createElement('Menu') - self.__addXmlTextElement(node, 'Name', name) - return element.appendChild(node) + menu_node = etree.SubElement('Menu', element) + name_node = etree.SubElement('Name', menu_node) + name_node.text = name + return menu_node def __addXmlTextElement(self, element, name, text): - node = self.doc.createElement(name) - text = self.doc.createTextNode(text) - node.appendChild(text) - return element.appendChild(node) + node = etree.SubElement(name, element) + node.text = text + return node - def __addXmlFilename(self, element, filename, type = "Include"): + def __addXmlFilename(self, element, filename, type_="Include"): # remove old filenames - for node in self.__getXmlNodesByName(["Include", "Exclude"], element): - if node.childNodes[0].nodeName == "Filename" and node.childNodes[0].childNodes[0].nodeValue == filename: - element.removeChild(node) + includes = element.findall('Include') + excludes = element.findall('Exclude') + rules = includes + excludes + for rule in rules: + #FIXME: this finds only Rules whose FIRST child is a Filename element + if rule[0].tag == "Filename" and rule[0].text == filename: + element.remove(rule) + # shouldn't it remove all occurences, like the following: + #filename_nodes = rule.findall('.//Filename'): + #for fn in filename_nodes: + #if fn.text == filename: + ##element.remove(rule) + #parent = self.__get_parent_node(fn) + #parent.remove(fn) # add new filename - node = self.doc.createElement(type) - node.appendChild(self.__addXmlTextElement(node, 'Filename', filename)) - return element.appendChild(node) + node = etree.SubElement(type_, element) + self.__addXmlTextElement(node, 'Filename', filename) + return node def __addXmlMove(self, element, old, new): - node = self.doc.createElement("Move") - node.appendChild(self.__addXmlTextElement(node, 'Old', old)) - node.appendChild(self.__addXmlTextElement(node, 'New', new)) - return element.appendChild(node) + node = etree.SubElement("Move", element) + self.__addXmlTextElement(node, 'Old', old) + self.__addXmlTextElement(node, 'New', new) + return node def __addXmlLayout(self, element, layout): # remove old layout - for node in self.__getXmlNodesByName("Layout", element): - element.removeChild(node) + for node in element.findall("Layout"): + element.remove(node) # add new layout - node = self.doc.createElement("Layout") + node = etree.SubElement("Layout", element) for order in layout.order: if order[0] == "Separator": - child = self.doc.createElement("Separator") - node.appendChild(child) + child = etree.SubElement("Separator", node) elif order[0] == "Filename": child = self.__addXmlTextElement(node, "Filename", order[1]) elif order[0] == "Menuname": child = self.__addXmlTextElement(node, "Menuname", order[1]) elif order[0] == "Merge": - child = self.doc.createElement("Merge") - child.setAttribute("type", order[1]) - node.appendChild(child) - return element.appendChild(node) - - def __getXmlNodesByName(self, name, element): - for child in element.childNodes: - if child.nodeType == xml.dom.Node.ELEMENT_NODE and child.nodeName in name: - yield child + child = etree.SubElement("Merge", node) + child.attrib["type"] = order[1] + return node def __addLayout(self, parent): layout = Layout() @@ -498,14 +518,24 @@ class MenuEditor: except ValueError: pass - def __remove_whilespace_nodes(self, node): - remove_list = [] - for child in node.childNodes: - if child.nodeType == xml.dom.minidom.Node.TEXT_NODE: - child.data = child.data.strip() - if not child.data.strip(): - remove_list.append(child) - elif child.hasChildNodes(): + def __remove_whitespace_nodes(self, node): + for child in node: + text = child.text.strip() + if not text: + child.text = '' + tail = child.tail.strip() + if not tail: + child.tail = '' + if len(child): self.__remove_whilespace_nodes(child) - for node in remove_list: - node.parentNode.removeChild(node) + + def __get_parent_node(self, node): + # elements in ElementTree doesn't hold a reference to their parent + for parent, child in self.__iter_parent(): + if child is node: + return child + + def __iter_parent(self): + for parent in self.tree.getiterator(): + for child in parent: + yield parent, child diff --git a/libs/xdg/Mime.py b/libs/xdg/Mime.py index b20159e5..3bff8b26 100644 --- a/libs/xdg/Mime.py +++ b/libs/xdg/Mime.py @@ -20,6 +20,7 @@ information about the format of these files. """ import os +import re import stat import sys import fnmatch @@ -46,25 +47,42 @@ def _get_node_data(node): return ''.join([n.nodeValue for n in node.childNodes]).strip() def lookup(media, subtype = None): - """Get the MIMEtype object for this type, creating a new one if needed. + """Get the MIMEtype object for the given type. + + This remains for backwards compatibility; calling MIMEtype now does + the same thing. The name can either be passed as one part ('text/plain'), or as two ('text', 'plain'). """ - if subtype is None and '/' in media: - media, subtype = media.split('/', 1) - if (media, subtype) not in types: - types[(media, subtype)] = MIMEtype(media, subtype) - return types[(media, subtype)] + return MIMEtype(media, subtype) -class MIMEtype: - """Type holding data about a MIME type""" - def __init__(self, media, subtype): - "Don't use this constructor directly; use mime.lookup() instead." - assert media and '/' not in media - assert subtype and '/' not in subtype - assert (media, subtype) not in types +class MIMEtype(object): + """Class holding data about a MIME type. + + Calling the class will return a cached instance, so there is only one + instance for each MIME type. The name can either be passed as one part + ('text/plain'), or as two ('text', 'plain'). + """ + def __new__(cls, media, subtype=None): + if subtype is None and '/' in media: + media, subtype = media.split('/', 1) + assert '/' not in subtype + media = media.lower() + subtype = subtype.lower() + + try: + return types[(media, subtype)] + except KeyError: + mtype = super(MIMEtype, cls).__new__(cls) + mtype._init(media, subtype) + types[(media, subtype)] = mtype + return mtype + # If this is done in __init__, it is automatically called again each time + # the MIMEtype is returned by __new__, which we don't want. So we call it + # explicitly only when we construct a new instance. + def _init(self, media, subtype): self.media = media self.subtype = subtype self._comment = None @@ -109,100 +127,106 @@ class MIMEtype: return self.media + '/' + self.subtype def __repr__(self): - return '<%s: %s>' % (self, self._comment or '(comment not loaded)') + return 'MIMEtype(%r, %r)' % (self.media, self.subtype) + + def __hash__(self): + return hash(self.media) ^ hash(self.subtype) + +class UnknownMagicRuleFormat(ValueError): + pass + +class DiscardMagicRules(Exception): + "Raised when __NOMAGIC__ is found, and caught to discard previous rules." + pass class MagicRule: - def __init__(self, f): - self.next=None - self.prev=None - + also = None + + def __init__(self, start, value, mask, word, range): + self.start = start + self.value = value + self.mask = mask + self.word = word + self.range = range + + rule_ending_re = re.compile(br'(?:~(\d+))?(?:\+(\d+))?\n$') + + @classmethod + def from_file(cls, f): + """Read a rule from the binary magics file. Returns a 2-tuple of + the nesting depth and the MagicRule.""" + line = f.readline() #print line - ind=b'' - while True: - c=f.read(1) - if c == b'>': - break - ind+=c - if not ind: - self.nest=0 - else: - self.nest=int(ind.decode('ascii')) - - start = b'' - while True: - c = f.read(1) - if c == b'=': - break - start += c - self.start = int(start.decode('ascii')) - hb=f.read(1) - lb=f.read(1) - self.lenvalue = ord(lb)+(ord(hb)<<8) + # [indent] '>' + nest_depth, line = line.split(b'>', 1) + nest_depth = int(nest_depth) if nest_depth else 0 - self.value = f.read(self.lenvalue) - - c = f.read(1) - if c == b'&': - self.mask = f.read(self.lenvalue) - c = f.read(1) - else: - self.mask=None - - if c == b'~': - w = b'' - while c!=b'+' and c!=b'\n': - c=f.read(1) - if c==b'+' or c==b'\n': - break - w+=c - - self.word=int(w.decode('ascii')) - else: - self.word=1 - - if c==b'+': - r=b'' - while c!=b'\n': - c=f.read(1) - if c==b'\n': - break - r+=c - #print r - self.range = int(r.decode('ascii')) - else: - self.range = 1 - - if c != b'\n': - raise ValueError('Malformed MIME magic line') - - def getLength(self): - return self.start+self.lenvalue+self.range - - def appendRule(self, rule): - if self.nest= 3: + lenvalue = int.from_bytes(line[:2], byteorder='big') + else: + lenvalue = (ord(line[0])<<8)+ord(line[1]) + line = line[2:] + + # value + # This can contain newlines, so we may need to read more lines + while len(line) <= lenvalue: + line += f.readline() + value, line = line[:lenvalue], line[lenvalue:] + + # ['&' mask] + if line.startswith(b'&'): + # This can contain newlines, so we may need to read more lines + while len(line) <= lenvalue: + line += f.readline() + mask, line = line[1:lenvalue+1], line[lenvalue+1:] + else: + mask = None + + # ['~' word-size] ['+' range-length] + ending = cls.rule_ending_re.match(line) + if not ending: + # Per the spec, this will be caught and ignored, to allow + # for future extensions. + raise UnknownMagicRuleFormat(repr(line)) + + word, range = ending.groups() + word = int(word) if (word is not None) else 1 + range = int(range) if (range is not None) else 1 + + return nest_depth, cls(start, value, mask, word, range) + + def maxlen(self): + l = self.start + len(self.value) + self.range + if self.also: + return max(l, self.also.maxlen()) + return l + def match(self, buffer): if self.match0(buffer): - if self.next: - return self.next.match(buffer) + if self.also: + return self.also.match(buffer) return True def match0(self, buffer): l=len(buffer) + lenvalue = len(self.value) for o in range(self.range): s=self.start+o - e=s+self.lenvalue + e=s+lenvalue if l%d=[%d]%r&%r~%d+%d>' % (self.nest, + return 'MagicRule(start=%r, value=%r, mask=%r, word=%r, range=%r)' %( self.start, - self.lenvalue, self.value, self.mask, self.word, self.range) -class MagicType: - def __init__(self, mtype): - self.mtype=mtype - self.top_rules=[] - self.last_rule=None - - def getLine(self, f): - nrule=MagicRule(f) - - if nrule.nest and self.last_rule: - self.last_rule.appendRule(nrule) - else: - self.top_rules.append(nrule) - - self.last_rule=nrule - - return nrule +class MagicMatchAny(object): + """Match any of a set of magic rules. + + This has a similar interface to MagicRule objects (i.e. its match() and + maxlen() methods), to allow for duck typing. + """ + def __init__(self, rules): + self.rules = rules + def match(self, buffer): - for rule in self.top_rules: - if rule.match(buffer): - return self.mtype - - def __repr__(self): - return '' % self.mtype + return any(r.match(buffer) for r in self.rules) + + def maxlen(self): + return max(r.maxlen() for r in self.rules) + + @classmethod + def from_file(cls, f): + """Read a set of rules from the binary magic file.""" + c=f.read(1) + f.seek(-1, 1) + depths_rules = [] + while c and c != b'[': + try: + depths_rules.append(MagicRule.from_file(f)) + except UnknownMagicRuleFormat: + # Ignored to allow for extensions to the rule format. + pass + c=f.read(1) + if c: + f.seek(-1, 1) + + # Build the rule tree + tree = [] # (rule, [(subrule,[subsubrule,...]), ...]) + insert_points = {0:tree} + for depth, rule in depths_rules: + subrules = [] + insert_points[depth].append((rule, subrules)) + insert_points[depth+1] = subrules + + return cls.from_rule_tree(tree) + + @classmethod + def from_rule_tree(cls, tree): + """From a nested list of (rule, subrules) pairs, build a MagicMatchAny + instance, recursing down the tree. + + Where there's only one top-level rule, this is returned directly, + to simplify the nested structure. Returns None if no rules were read. + """ + rules = [] + for rule, subrules in tree: + if subrules: + rule.also = cls.from_rule_tree(subrules) + rules.append(rule) + + if len(rules)==0: + return None + if len(rules)==1: + return rules[0] + return cls(rules) class MagicDB: def __init__(self): - self.types={} # Indexed by priority, each entry is a list of type rules - self.maxlen=0 + self.bytype = defaultdict(list) # mimetype -> [(priority, rule), ...] - def mergeFile(self, fname): + def merge_file(self, fname): + """Read a magic binary file, and add its rules to this MagicDB.""" with open(fname, 'rb') as f: line = f.readline() if line != b'MIME-Magic\0\n': @@ -262,68 +321,210 @@ class MagicDB: while True: shead = f.readline().decode('ascii') - #print shead + #print(shead) if not shead: break if shead[0] != '[' or shead[-2:] != ']\n': - raise ValueError('Malformed section heading') + raise ValueError('Malformed section heading', shead) pri, tname = shead[1:-2].split(':') #print shead[1:-2] pri = int(pri) mtype = lookup(tname) - try: - ents = self.types[pri] - except: - ents = [] - self.types[pri] = ents + rule = MagicMatchAny.from_file(f) + except DiscardMagicRules: + self.bytype.pop(mtype, None) + rule = MagicMatchAny.from_file(f) + if rule is None: + continue + #print rule - magictype = MagicType(mtype) - #print tname + self.bytype[mtype].append((pri, rule)) - #rline=f.readline() - c=f.read(1) - f.seek(-1, 1) - while c and c != b'[': - rule=magictype.getLine(f) - #print rule - if rule and rule.getLength() > self.maxlen: - self.maxlen = rule.getLength() + def finalise(self): + """Prepare the MagicDB for matching. + + This should be called after all rules have been merged into it. + """ + maxlen = 0 + self.alltypes = [] # (priority, mimetype, rule) - c = f.read(1) - f.seek(-1, 1) + for mtype, rules in self.bytype.items(): + for pri, rule in rules: + self.alltypes.append((pri, mtype, rule)) + maxlen = max(maxlen, rule.maxlen()) - ents.append(magictype) - #self.types[pri]=ents - if not c: - break + self.maxlen = maxlen # Number of bytes to read from files + self.alltypes.sort(key=lambda x: x[0], reverse=True) - def match_data(self, data, max_pri=100, min_pri=0): - for priority in sorted(self.types.keys(), reverse=True): + def match_data(self, data, max_pri=100, min_pri=0, possible=None): + """Do magic sniffing on some bytes. + + max_pri & min_pri can be used to specify the maximum & minimum priority + rules to look for. possible can be a list of mimetypes to check, or None + (the default) to check all mimetypes until one matches. + + Returns the MIMEtype found, or None if no entries match. + """ + if possible is not None: + types = [] + for mt in possible: + for pri, rule in self.bytype[mt]: + types.append((pri, mt, rule)) + types.sort(key=lambda x: x[0]) + else: + types = self.alltypes + + for priority, mimetype, rule in types: #print priority, max_pri, min_pri if priority > max_pri: continue if priority < min_pri: break - for type in self.types[priority]: - m=type.match(data) - if m: - return m + + if rule.match(data): + return mimetype - def match(self, path, max_pri=100, min_pri=0): - try: - with open(path, 'rb') as f: - buf = f.read(self.maxlen) - return self.match_data(buf, max_pri, min_pri) - except: - pass + def match(self, path, max_pri=100, min_pri=0, possible=None): + """Read data from the file and do magic sniffing on it. + + max_pri & min_pri can be used to specify the maximum & minimum priority + rules to look for. possible can be a list of mimetypes to check, or None + (the default) to check all mimetypes until one matches. + + Returns the MIMEtype found, or None if no entries match. Raises IOError + if the file can't be opened. + """ + with open(path, 'rb') as f: + buf = f.read(self.maxlen) + return self.match_data(buf, max_pri, min_pri, possible) def __repr__(self): - return '' % self.types + return '' % len(self.alltypes) + +class GlobDB(object): + def __init__(self): + """Prepare the GlobDB. It can't actually be used until .finalise() is + called, but merge_file() can be used to add data before that. + """ + # Maps mimetype to {(weight, glob, flags), ...} + self.allglobs = defaultdict(set) + + def merge_file(self, path): + """Loads name matching information from a globs2 file."""# + allglobs = self.allglobs + with open(path) as f: + for line in f: + if line.startswith('#'): continue # Comment + + fields = line[:-1].split(':') + weight, type_name, pattern = fields[:3] + weight = int(weight) + mtype = lookup(type_name) + if len(fields) > 3: + flags = fields[3].split(',') + else: + flags = () + + if pattern == '__NOGLOBS__': + # This signals to discard any previous globs + allglobs.pop(mtype, None) + continue + + allglobs[mtype].add((weight, pattern, tuple(flags))) + + def finalise(self): + """Prepare the GlobDB for matching. + + This should be called after all files have been merged into it. + """ + self.exts = defaultdict(list) # Maps extensions to [(type, weight),...] + self.cased_exts = defaultdict(list) + self.globs = [] # List of (regex, type, weight) triplets + self.literals = {} # Maps literal names to (type, weight) + self.cased_literals = {} + + for mtype, globs in self.allglobs.items(): + mtype = mtype.canonical() + for weight, pattern, flags in globs: + + cased = 'cs' in flags + + if pattern.startswith('*.'): + # *.foo -- extension pattern + rest = pattern[2:] + if not ('*' in rest or '[' in rest or '?' in rest): + if cased: + self.cased_exts[rest].append((mtype, weight)) + else: + self.exts[rest.lower()].append((mtype, weight)) + continue + if ('*' in pattern or '[' in pattern or '?' in pattern): + # Translate the glob pattern to a regex & compile it + re_flags = 0 if cased else re.I + pattern = re.compile(fnmatch.translate(pattern), flags=re_flags) + self.globs.append((pattern, mtype, weight)) + else: + # No wildcards - literal pattern + if cased: + self.cased_literals[pattern] = (mtype, weight) + else: + self.literals[pattern.lower()] = (mtype, weight) + + # Sort globs by weight & length + self.globs.sort(reverse=True, key=lambda x: (x[2], len(x[0].pattern)) ) + + def first_match(self, path): + """Return the first match found for a given path, or None if no match + is found.""" + try: + return next(self._match_path(path))[0] + except StopIteration: + return None + + def all_matches(self, path): + """Return a list of (MIMEtype, glob weight) pairs for the path.""" + return list(self._match_path(path)) + + def _match_path(self, path): + """Yields pairs of (mimetype, glob weight).""" + leaf = os.path.basename(path) + + # Literals (no wildcards) + if leaf in self.cased_literals: + yield self.cased_literals[leaf] + + lleaf = leaf.lower() + if lleaf in self.literals: + yield self.literals[lleaf] + + # Extensions + ext = leaf + while 1: + p = ext.find('.') + if p < 0: break + ext = ext[p + 1:] + if ext in self.cased_exts: + for res in self.cased_exts[ext]: + yield res + ext = lleaf + while 1: + p = ext.find('.') + if p < 0: break + ext = ext[p+1:] + if ext in self.exts: + for res in self.exts[ext]: + yield res + + # Other globs + for (regex, mime_type, weight) in self.globs: + if regex.match(leaf): + yield (mime_type, weight) # Some well-known types text = lookup('text', 'plain') +octet_stream = lookup('application', 'octet-stream') inode_block = lookup('inode', 'blockdevice') inode_char = lookup('inode', 'chardevice') inode_dir = lookup('inode', 'directory') @@ -336,44 +537,12 @@ app_exe = lookup('application', 'executable') _cache_uptodate = False def _cache_database(): - global exts, globs, literals, magic, aliases, inheritance, _cache_uptodate + global globs, magic, aliases, inheritance, _cache_uptodate _cache_uptodate = True - exts = {} # Maps extensions to types - globs = [] # List of (glob, type) pairs - literals = {} # Maps literal names to types aliases = {} # Maps alias Mime types to canonical names inheritance = defaultdict(set) # Maps to sets of parent mime types. - magic = MagicDB() - - def _import_glob_file(path): - """Loads name matching information from a MIME directory.""" - with open(path) as f: - for line in f: - if line.startswith('#'): continue - line = line[:-1] - - type_name, pattern = line.split(':', 1) - mtype = lookup(type_name) - - if pattern.startswith('*.'): - rest = pattern[2:] - if not ('*' in rest or '[' in rest or '?' in rest): - exts[rest] = mtype - continue - if '*' in pattern or '[' in pattern or '?' in pattern: - globs.append((pattern, mtype)) - else: - literals[pattern] = mtype - - for path in BaseDirectory.load_data_paths(os.path.join('mime', 'globs')): - _import_glob_file(path) - for path in BaseDirectory.load_data_paths(os.path.join('mime', 'magic')): - magic.mergeFile(path) - - # Sort globs by length - globs.sort(key=lambda x: len(x[0]) ) # Load aliases for path in BaseDirectory.load_data_paths(os.path.join('mime', 'aliases')): @@ -382,6 +551,18 @@ def _cache_database(): alias, canonical = line.strip().split(None, 1) aliases[alias] = canonical + # Load filename patterns (globs) + globs = GlobDB() + for path in BaseDirectory.load_data_paths(os.path.join('mime', 'globs2')): + globs.merge_file(path) + globs.finalise() + + # Load magic sniffing data + magic = MagicDB() + for path in BaseDirectory.load_data_paths(os.path.join('mime', 'magic')): + magic.merge_file(path) + magic.finalise() + # Load subclasses for path in BaseDirectory.load_data_paths(os.path.join('mime', 'subclasses')): with open(path, 'r') as f: @@ -396,35 +577,7 @@ def update_cache(): def get_type_by_name(path): """Returns type of file by its name, or None if not known""" update_cache() - - leaf = os.path.basename(path) - if leaf in literals: - return literals[leaf] - - lleaf = leaf.lower() - if lleaf in literals: - return literals[lleaf] - - ext = leaf - while 1: - p = ext.find('.') - if p < 0: break - ext = ext[p + 1:] - if ext in exts: - return exts[ext] - ext = lleaf - while 1: - p = ext.find('.') - if p < 0: break - ext = ext[p+1:] - if ext in exts: - return exts[ext] - for (glob, mime_type) in globs: - if fnmatch.fnmatch(leaf, glob): - return mime_type - if fnmatch.fnmatch(lleaf, glob): - return mime_type - return None + return globs.first_match(path) def get_type_by_contents(path, max_pri=100, min_pri=0): """Returns type of file by its contents, or None if not known""" @@ -438,15 +591,24 @@ def get_type_by_data(data, max_pri=100, min_pri=0): return magic.match_data(data, max_pri, min_pri) +def _get_type_by_stat(st_mode): + """Match special filesystem objects to Mimetypes.""" + if stat.S_ISDIR(st_mode): return inode_dir + elif stat.S_ISCHR(st_mode): return inode_char + elif stat.S_ISBLK(st_mode): return inode_block + elif stat.S_ISFIFO(st_mode): return inode_fifo + elif stat.S_ISLNK(st_mode): return inode_symlink + elif stat.S_ISSOCK(st_mode): return inode_socket + return inode_door + def get_type(path, follow=True, name_pri=100): """Returns type of file indicated by path. - path : - pathname to check (need not exist) - follow : - when reading file, follow symbolic links - name_pri : - Priority to do name matches. 100=override magic + This function is *deprecated* - :func:`get_type2` is more accurate. + + :param path: pathname to check (need not exist) + :param follow: when reading file, follow symbolic links + :param name_pri: Priority to do name matches. 100=override magic This tries to use the contents of the file, and falls back to the name. It can also handle special filesystem objects like directories and sockets. @@ -463,6 +625,7 @@ def get_type(path, follow=True, name_pri=100): return t or text if stat.S_ISREG(st.st_mode): + # Regular file t = get_type_by_contents(path, min_pri=name_pri) if not t: t = get_type_by_name(path) if not t: t = get_type_by_contents(path, max_pri=name_pri) @@ -472,13 +635,112 @@ def get_type(path, follow=True, name_pri=100): else: return text return t - elif stat.S_ISDIR(st.st_mode): return inode_dir - elif stat.S_ISCHR(st.st_mode): return inode_char - elif stat.S_ISBLK(st.st_mode): return inode_block - elif stat.S_ISFIFO(st.st_mode): return inode_fifo - elif stat.S_ISLNK(st.st_mode): return inode_symlink - elif stat.S_ISSOCK(st.st_mode): return inode_socket - return inode_door + else: + return _get_type_by_stat(st.st_mode) + +def get_type2(path, follow=True): + """Find the MIMEtype of a file using the XDG recommended checking order. + + This first checks the filename, then uses file contents if the name doesn't + give an unambiguous MIMEtype. It can also handle special filesystem objects + like directories and sockets. + + :param path: file path to examine (need not exist) + :param follow: whether to follow symlinks + + :rtype: :class:`MIMEtype` + + .. versionadded:: 1.0 + """ + update_cache() + + try: + st = os.stat(path) if follow else os.lstat(path) + except OSError: + return get_type_by_name(path) or octet_stream + + if not stat.S_ISREG(st.st_mode): + # Special filesystem objects + return _get_type_by_stat(st.st_mode) + + mtypes = sorted(globs.all_matches(path), key=(lambda x: x[1]), reverse=True) + if mtypes: + max_weight = mtypes[0][1] + i = 1 + for mt, w in mtypes[1:]: + if w < max_weight: + break + i += 1 + mtypes = mtypes[:i] + if len(mtypes) == 1: + return mtypes[0][0] + + possible = [mt for mt,w in mtypes] + else: + possible = None # Try all magic matches + + try: + t = magic.match(path, possible=possible) + except IOError: + t = None + + if t: + return t + elif mtypes: + return mtypes[0][0] + elif stat.S_IMODE(st.st_mode) & 0o111: + return app_exe + else: + return text if is_text_file(path) else octet_stream + +def is_text_file(path): + """Guess whether a file contains text or binary data. + + Heuristic: binary if the first 32 bytes include ASCII control characters. + This rule may change in future versions. + + .. versionadded:: 1.0 + """ + try: + f = open(path, 'rb') + except IOError: + return False + + with f: + return _is_text(f.read(32)) + +if PY3: + def _is_text(data): + return not any(b <= 0x8 or 0xe <= b < 0x20 or b == 0x7f for b in data) +else: + def _is_text(data): + return not any(b <= '\x08' or '\x0e' <= b < '\x20' or b == '\x7f' \ + for b in data) + +_mime2ext_cache = None +_mime2ext_cache_uptodate = False + +def get_extensions(mimetype): + """Retrieve the set of filename extensions matching a given MIMEtype. + + Extensions are returned without a leading dot, e.g. 'py'. If no extensions + are registered for the MIMEtype, returns an empty set. + + The extensions are stored in a cache the first time this is called. + + .. versionadded:: 1.0 + """ + global _mime2ext_cache, _mime2ext_cache_uptodate + update_cache() + if not _mime2ext_cache_uptodate: + _mime2ext_cache = defaultdict(set) + for ext, mtypes in globs.exts.items(): + for mtype, prio in mtypes: + _mime2ext_cache[mtype].add(ext) + _mime2ext_cache_uptodate = True + + return _mime2ext_cache[mimetype] + def install_mime_info(application, package_file): """Copy 'package_file' as ``~/.local/share/mime/packages/.xml.`` diff --git a/libs/xdg/RecentFiles.py b/libs/xdg/RecentFiles.py index 10489468..3038b578 100644 --- a/libs/xdg/RecentFiles.py +++ b/libs/xdg/RecentFiles.py @@ -1,5 +1,5 @@ """ -Implementation of the XDG Recent File Storage Specification Version 0.2 +Implementation of the XDG Recent File Storage Specification http://standards.freedesktop.org/recent-file-spec """ diff --git a/libs/xdg/__init__.py b/libs/xdg/__init__.py index 2bddf009..b5a117ea 100644 --- a/libs/xdg/__init__.py +++ b/libs/xdg/__init__.py @@ -1,3 +1,3 @@ __all__ = [ "BaseDirectory", "DesktopEntry", "Menu", "Exceptions", "IniFile", "IconTheme", "Locale", "Config", "Mime", "RecentFiles", "MenuEditor" ] -__version__ = "0.25" +__version__ = "0.26" diff --git a/libs/xdg/util.py b/libs/xdg/util.py index 5d54e4b8..1637aa5e 100644 --- a/libs/xdg/util.py +++ b/libs/xdg/util.py @@ -9,3 +9,67 @@ else: # Unicode-like literals def u(s): return s.decode('utf-8') + +try: + # which() is available from Python 3.3 + from shutil import which +except ImportError: + import os + # This is a copy of which() from Python 3.3 + def which(cmd, mode=os.F_OK | os.X_OK, path=None): + """Given a command, mode, and a PATH string, return the path which + conforms to the given mode on the PATH, or None if there is no such + file. + + `mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result + of os.environ.get("PATH"), or can be overridden with a custom search + path. + + """ + # Check that a given file can be accessed with the correct mode. + # Additionally check that `file` is not a directory, as on Windows + # directories pass the os.access check. + def _access_check(fn, mode): + return (os.path.exists(fn) and os.access(fn, mode) + and not os.path.isdir(fn)) + + # If we're given a path with a directory part, look it up directly rather + # than referring to PATH directories. This includes checking relative to the + # current directory, e.g. ./script + if os.path.dirname(cmd): + if _access_check(cmd, mode): + return cmd + return None + + path = (path or os.environ.get("PATH", os.defpath)).split(os.pathsep) + + if sys.platform == "win32": + # The current directory takes precedence on Windows. + if not os.curdir in path: + path.insert(0, os.curdir) + + # PATHEXT is necessary to check on Windows. + pathext = os.environ.get("PATHEXT", "").split(os.pathsep) + # See if the given file matches any of the expected path extensions. + # This will allow us to short circuit when given "python.exe". + # If it does match, only test that one, otherwise we have to try + # others. + if any(cmd.lower().endswith(ext.lower()) for ext in pathext): + files = [cmd] + else: + files = [cmd + ext for ext in pathext] + else: + # On other platforms you don't have things like PATHEXT to tell you + # what file suffixes are executable, so just pass on cmd as-is. + files = [cmd] + + seen = set() + for dir in path: + normdir = os.path.normcase(dir) + if not normdir in seen: + seen.add(normdir) + for thefile in files: + name = os.path.join(dir, thefile) + if _access_check(name, mode): + return name + return None