Source code for launcher_menus.functions

#!/usr/bin/env python3
# -*- coding: utf-8; mode: python -*-
#
# Copyright 2021 Pradyumna Paranjape
# This file is part of launcher-menus.
#
# launcher-menus is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# launcher-menus is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with launcher-menus.  If not, see <https://www.gnu.org/licenses/>.
#
'''
Menu object and function
'''

import os
import re
import subprocess
import warnings
from pathlib import Path
from typing import Dict, List, Optional, Union

import yaml

from .checks import MENUS
from .errors import CommandError, FlagNameNotFoundError, UsageError


def arg2flag(arg: str) -> List[str]:
    '''
    Convert argument to flag

    Args:
       arg: Convert this argument to possible flag

    Returns:
        A list of possible flags generated from ``arg``

    '''
    flags = []
    # arg is of the type flag_name
    flags.append("--" + arg)  # --flag_name
    flags.append("--" + arg.replace("_", "-"))  # --flag-name
    flags.append("-" + arg[0])  # -f  ``THIS MAY BE AMBIGUOUS``
    return flags


def process_comm(cmd: list,
                 pipe_inputs: str = '',
                 timeout: float = None,
                 **kwargs) -> str:
    '''
    Args:
        cmd: list form of commands to be passed to Popen as args
        pipe_inputs: inputs to be passed as stdin
        timeout: timeout of communication in seconds
        **kwargs: passed to Popen

    Raises:
        UsageError: Command usage error
        CommandError: can't open process/ stderr from process

    Return
        stdout: str: returned by process

    '''
    try:
        proc = subprocess.Popen(cmd,
                                universal_newlines=True,
                                stdin=subprocess.PIPE,
                                stdout=subprocess.PIPE,
                                stderr=subprocess.PIPE,
                                text=True,
                                **kwargs)
    except OSError as err:
        raise CommandError(cmd, err) from err

    stdout, stderr = proc.communicate(input=pipe_inputs, timeout=timeout)
    if stderr:
        if re.match('usage', stderr, re.I):
            raise UsageError(cmd, stderr)
        raise CommandError(cmd, stderr)
    return stdout.rstrip('\n')


[docs]class LauncherMenu(): ''' Launcher Menu wrapper object with pre-defined menu options. Args: opts: list: options to be offerred by menu. command: command to use {dmenu,bemenu,<custom>} flag_names: dict providing action: flags or path to cognate yaml. fail: 'warn': warn, 'fail': error, 'guess': try creating, else warn **kwargs: default values for ``kwargs`` of ``menu`` Attributes: opts: default options to be offerred command: default menu command to run flag_names: dictionary of {actions: flag_names} fail: default failure behaviour Raises: TypeError FlagNameNotFoundError ''' def __init__(self, opts: List[str] = None, command: str = None, flag_names: Union[os.PathLike, dict] = None, fail: str = 'warn', **kwargs) -> None: self.opts = opts self.command = command self.flag_names = self._read_flag_names(flag_names) # ``flag_names`` has two dicts: bool, input # bool flags are only 'flagged' in command line # input flags demand an accompanying input value self.fail = fail # Categorise self.flag_names = self._categorize_flags(self.flag_names) self.kwargs = kwargs def __call__(self, opts: List[str] = None, command: str = None, flag_names: Union[os.PathLike, dict] = None, fail: str = 'warn', **kwargs) -> Optional[str]: ''' Call <command> menu to collect interactive information. Args: opts: list: options to be offerred by menu. command: command to use {dmenu,bemenu,<custom>} flag_names: dict providing action: flags or path to cognate yaml. fail: 'warn': warn, 'fail': error, 'guess': try creating, else warn kwargs: flag to be called at command line: - bottom = ``bool``: show bar at bottom - grab = ``bool``: show menu before reading stdin (faster) - ignorecase = ``bool``: match items ignoring case - wrap = ``bool``: wrap cursor selection - ifne = ``bool``: display only if opts - nooverlap = ``bool``: do not overlap panels - lines = ``int``: list opts on vertical 'lines' - monitor = ``int``: show menu on (bemenu w/ wayland: -1: all) - height = ``int``: height of each menu line - index = ``int``: select index automatically - prompt = ``str``: prompt string of menu - prefix = ``str``: prefix added highlighted item - scrollbar = ``str``: display scrollbar {none,always,autohide} - font = ``str``: font to be used format: "FONT-NAME [SIZE ]" - title_background = ``str``: title background color - title_foreground = ``str``: title foreground color - normal_background = ``str``: normal background color - normal_foreground = ``str``: normal foreground color - filter_background = ``str``: filter background color - filter_foreground = ``str``: filter foreground color - high_background = ``str``: highlight background color - high_foreground = ``str``: highlight foreground color - scroll_background = ``str``: scrollbar background color - scroll_foreground = ``str``: scrollbar foreground color - selected_background = ``str``: selected background color - selected_foreground = ``str``: selected foreground color - windowid = ``str``: embed into windowid Raises: CommandError UsageError FlagNameNotFoundError ValueError: bad scrollbar options Returns: User's selected opt from ``opts`` or overridden-entered choice else ``None`` [Esc] ''' return self.menu(opts=opts, command=command, flag_names=flag_names, fail=fail, **kwargs)
[docs] def menu(self, opts: List[str] = None, command: str = None, flag_names: Union[os.PathLike, dict] = None, fail: str = 'warn', **kwargs) -> Optional[str]: ''' Call menu ''' # Command command = command or self.command or list(MENUS.keys())[0] # Flags empty_flags: Dict[str, dict] = {'bool': {}, 'input': {}} flag_names = self._read_flag_names(flag_names) for key, value in flag_names.items(): flag_names[key] = { **self.flag_names[key], **MENUS.get(command, empty_flags)[key], **value } # Similarly, called options: kwargs = {**self.kwargs, **kwargs} cmd: List[Union[List[str], str]] = [command] # boolean flags for key, value in flag_names['bool'].items(): if value is not None and key in kwargs and kwargs[key]: try: cmd.append(value) # type: ignore except KeyError as err: if fail == 'fail': raise FlagNameNotFoundError(command, err.args[0]) from err warnings.warn(''' flag name for '{key}' of {command} was not found but not failing ''') if fail == 'guess': cmd.append(arg2flag(key)) # input flags for key, value in flag_names['input'].items(): if value is not None and key in kwargs: if key == 'scrollbar' and value not in [ 'none', 'always', 'autohide' ]: raise ValueError(f""" scrollbar should be in ['none', 'always', 'autohide'], got {value} """) try: cmd.extend((value, str(kwargs[key]))) # type: ignore except KeyError as err: if fail: raise FlagNameNotFoundError(command, err.args[0]) from err warnings.warn(''' flag name for '{key}' of {command} was not found but not failing ''') if fail == 'guess': cmd.extend((arg2flag(key), str(value))) # unrecognozed flags opts = opts or [] opts = [str(choice) for choice in opts] return process_comm(cmd, pipe_inputs='\n'.join(opts)) or None
@staticmethod def _read_flag_names( flag_names: Union[os.PathLike, dict, None], ) -> Dict[str, dict]: ''' Interpret type of flag_names Args: flag_names: either a yml file or directly a dict Raises: FileNotFoundError ''' if isinstance(flag_names, dict): return flag_names if flag_names is None: return {'bool': {}, 'input': {}} if not isinstance(flag_names, os.PathLike): raise TypeError(''' flag_names should be either of str or dict ''') with open(flag_names, 'r') as yml_handle: return yaml.safe_load(yml_handle) @staticmethod def _categorize_flags(flag_names: Dict[str, dict]) -> Dict[str, dict]: ''' Classify flags into bool, input. If flag is unrecognized, classify based on its value Args: flag_names: dictionary to be updated with ``flags`` a yaml representation of the structure is below TODO: create a class structure flag_names: input: act 1: --flag-one act 2: --flag-two act 3: --flag-three bool: act 4: --flag-four act 5: --flag-five Returns: flag_names: with keys 'input', 'bool' with values: dictionaries, each containing 'action' : 'flag' pairs ''' # Don't mess up the referrence # deep copy altered_flag_names = {} altered_flag_names['bool'] = flag_names['bool'].copy() altered_flag_names['input'] = flag_names['input'].copy() for key, value in flag_names.items(): if key not in ('bool', 'input'): # This flag is unrecognized # try to guess its type if isinstance(value, bool): altered_flag_names['bool'][key] = value else: altered_flag_names['input'][key] = value return altered_flag_names