Source code for xdg.Menu

"""
Implementation of the XDG Menu Specification
http://standards.freedesktop.org/menu-spec/

Example code:

from xdg.Menu import parse, Menu, MenuEntry

def print_menu(menu, tab=0):
  for submenu in menu.Entries:
    if isinstance(submenu, Menu):
      print ("\t" * tab) + unicode(submenu)
      print_menu(submenu, tab+1)
    elif isinstance(submenu, MenuEntry):
      print ("\t" * tab) + unicode(submenu.DesktopEntry)

print_menu(parse())
"""

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
from xdg.util import PY3

import xdg.Locale
import xdg.Config


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 Move:
    "A move operation"
    def __init__(self, old="", new=""):
        self.Old = old
        self.New = new

    def __cmp__(self, other):
        return cmp(self.Old, other.Old)


class Layout:
    "Menu Layout class"
    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']
        ]

    @property
    def order(self):
        return self._order if self._order else self._default_order

    @order.setter
    def order(self, order):
        self._order = order


class Rule:
    """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
        # expression is ast.Expression
        self.expression = expression
        self.code = compile(self.expression, '<compiled-menu-rule>', 'eval')

    def __str__(self):
        return ast.dump(self.expression)

    def apply(self, menuentries, run):
        for menuentry in menuentries:
            if run == 2 and (menuentry.MatchedInclude is True or
                             menuentry.Allocated is True):
                continue
            if eval(self.code):
                if self.Type is Rule.TYPE_INCLUDE:
                    menuentry.Add = True
                    menuentry.MatchedInclude = True
                else:
                    menuentry.Add = False
        return menuentries





class Separator:
    "Just a dummy class for Separators"
    def __init__(self, parent):
        self.Parent = parent
        self.Show = True


class Header:
    "Class for Inline Headers"
    def __init__(self, name, generic_name, comment):
        self.Name = name
        self.GenericName = generic_name
        self.Comment = comment

    def __str__(self):
        return self.Name


TYPE_DIR, TYPE_FILE = 0, 1


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 not os.path.exists(value):
        return False
    if type == TYPE_DIR and os.path.isdir(value):
        return value
    if type == TYPE_FILE and os.path.isfile(value):
        return value
    return False


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)

    # ---------- <Rule> 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:
            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"
    def __init__(self):
        self.cacheEntries = {}
        self.cacheEntries['legacy'] = []
        self.cache = {}

    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 item.endswith(".desktop"):
                try:
                    menuentry = MenuEntry(os.path.join(subdir, item), dir_, prefix)
                except ParsingError:
                    continue

                self.cacheEntries[dir_].append(menuentry)
                if legacy:
                    self.cacheEntries['legacy'].append(menuentry)
            elif os.path.isdir(os.path.join(dir_, subdir, item)) and not legacy:
                self.__addFiles(dir_, os.path.join(subdir, item), prefix, legacy)

    def get_menu_entries(self, dirs, legacy=True):
        entries = []
        ids = set()
        # handle legacy items
        appdirs = dirs[:]
        if legacy:
            appdirs.append("legacy")
        # cache the results again
        key = "".join(appdirs)
        try:
            return self.cache[key]
        except KeyError:
            pass
        for dir_ in appdirs:
            for menuentry in self.cacheEntries[dir_]:
                try:
                    if menuentry.DesktopFileID not in ids:
                        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] = entries
        return entries


[docs]def parse(filename=None, debug=False): """Helper function. Equivalent to calling xdg.Menu.XMLMenuBuilder().parse(filename) """ return XMLMenuBuilder(debug).parse(filename)