Source code for visidata.settings

import collections
import functools
import sys
import inspect
import argparse
import importlib
import os

import visidata
from visidata import VisiData, BaseSheet, vd, AttrDict
from visidata.vendor.appdirs import user_config_dir, user_cache_dir, user_data_dir


# [settingname] -> { objname(Sheet-instance/Sheet-type/'global'/'default'): Option/Command/longname }
class SettingsMgr(collections.OrderedDict):
    def __init__(self):
        super().__init__()
        self.allobjs = {}

    def __hash__(self):
        return hash(id(self))

    def __eq__(self, other):
        return self is other

    def objname(self, obj):
        if isinstance(obj, str):
            v = obj
        elif obj is None:
            v = 'global'
        elif isinstance(obj, BaseSheet):
            v = obj.name
        elif inspect.isclass(obj) and issubclass(obj, BaseSheet):
            v = obj.__name__
        elif isinstance(obj, os.PathLike):
            v = str(obj)
        elif inspect.isclass(obj) and issubclass(obj, os.PathLike):
            v = obj.__name__
        else:
            return None

        if not isinstance(obj, str) or v not in self.allobjs:
            self.allobjs[v] = obj
        return v

    def getobj(self, objname):
        'Inverse of objname(obj); returns obj if available'
        return self.allobjs.get(objname)

    def unset(self, k, obj='default'):
        'Remove setting for given key in the given context.'
        objstr = self.objname(obj)
        if objstr in self[k]:
            del self[k][objstr]

    def set(self, k, v, obj):
        'obj is a Sheet instance, or a Sheet [sub]class.  obj="global" means override default unless there is a sheet-specific override; obj="default" means last resort.'
        if k not in self:
            self[k] = dict()
        self[k][self.objname(obj)] = v
        return v

    def setdefault(self, k, v):
        return self.set(k, v, 'default')

    @functools.lru_cache()
    def _mappings(self, obj):
        '''Return list of contexts in order to resolve settings. ordering is, from lowest to highest precedence:

        1. "default": default specified in option() definition
        2. "global": in order of program execution:
            a. .visidatarc
            b. command-line options, applied on top of the overrides in .visidatarc
            c. at runtime via 'O'ptions meta-sheet
        3. objname(type(obj)): current sheet class and parents, recursively
        4. objname(obj): the specific sheet instance
            a. can override at runtime, replace value for sheet instance
        '''
        mappings = []
        if obj:
            mappings += [obj]
            mappings += [self.objname(cls) for cls in inspect.getmro(type(obj))]

        mappings += ['global', 'default']
        return mappings

    def _get(self, key, obj=None):
        d = self.get(key, None)
        if d:
            for m in self._mappings(obj or vd.activeSheet):
                v = d.get(self.objname(m))
                if v is not None:
                    return v

    def iter(self, obj=None):
        'Iterate through all keys considering context of obj. If obj is None, uses the context of the top sheet.'
        if obj is None and vd:
            obj = vd.activeSheet

        for o in self._mappings(obj):
            o = self.objname(o)
            for k in self.keys():
                for o2 in self[k]:
                    if o == o2:
                        yield (k, o), self[k][o2]

    def iterall(self):
        for k in self.keys():
            for o in self[k]:
                yield (k, o), self[k][o]

    def resetToDefaults(self):
        'Remove global and instance-level settings, keeping defaults and class-level overrides.'
        for k in self:
            to_remove = [objname for objname in self[k]
                         if objname != 'default'
                         and not (inspect.isclass(self.allobjs.get(objname)))]
            for objname in to_remove:
                del self[k][objname]



class Command:
    def __init__(self, longname, execstr, helpstr='', module='', replay=True, deprecated=False, testable=True):
        self.longname = longname
        self.execstr = execstr
        self.helpstr = helpstr
        self.module = module
        self.deprecated = deprecated
        self.replayable = replay
        self.testable = testable


class Option:
    def __init__(self, name, value, description='', module='', help=''):
        # description gets shown on the manpage and the optionssheet; help is shown on the sidebar while editing
        self.name = name
        self.value = value
        self.helpstr = description
        self.extrahelp = help
        self.replayable = False
        self.sheettype = BaseSheet
        self.cli_only = False
        self.module = module

    def __str__(self):
        return str(self.value)

    def __eq__(self, other):
        return self.name == other.name


@VisiData.api
class OptionsObject:
    'minimalist options framework'
    _warned_unknown_options = set()

    def __init__(self, mgr, obj=None):
        object.__setattr__(self, '_opts', mgr)
        object.__setattr__(self, '_cache', {})
        object.__setattr__(self, '_obj', obj)

    def keys(self, obj=None):
        for k, d in self._opts.items():
            if obj is None or self._opts.objname(obj) in d:
                yield k

    def _get(self, k, obj=None):
        'Return Option object for k in context of obj. Cache result until any set().'
        opt = self._cache.get((k, obj or vd.activeSheet), None)
        if opt is None:
            opt = self._opts._get(k, obj)
            self._cache[(k, obj or vd.activeSheet)] = opt
        return opt

    def _set(self, k, v, obj=None, helpstr='', module=None):
        k, v = vd._resolve_optalias(k, v)  # to set deprecated and abbreviated options

        opt = self._get(k) or Option(k, v, '', module)
        self._cache.clear()  # invalidate entire cache on any change
        return self._opts.set(k, Option(k, v, opt.helpstr or helpstr, opt.module or module), obj)

    def is_set(self, k, obj=None):
        d = self._opts.get(k, None)
        if d:
            return d.get(self._opts.objname(obj), None)

    def get(self, optname, default=None):
        'Return the value of the given *optname* option in the options context. *default* is only returned if the option is not defined.  An Exception is never raised.'
        d = self._get(optname, None)
        if d:
            return d.value
        return default

    def getobj(self, optname, obj=None):
        'Return value of option optname as set on obj, or on option context if obj is None.'
        return self._get(optname, obj).value

    def getdefault(self, optname):
        return self._get(optname, 'default').value

    def _warn_unknown(self, optname):
        if optname not in self._warned_unknown_options:
            self._warned_unknown_options.add(optname)
            vd.warning(f'reading unknown option `{optname}`')

    def getonly(self, optname, obj, default):
        'Return value set on obj, default if unset on obj, or None if undefined.'
        d = self._opts.get(optname, None)
        if not d:
            return self._warn_unknown(optname)
        opt = d.get(self._opts.objname(obj), None)
        if opt:
            return opt.value
        return default

    def set(self, optname, value, obj='global', cmdlog=True):
        "Override *value* for *optname* in the options context, or in the *obj* context if given."
        opt = self._get(optname)
        module = None  # keep default
        if opt:
            curval = opt.value
            t = type(curval)
            if value is None and curval is not None:
                return self.unset(optname, obj=obj)
            elif isinstance(value, str) and t is bool: # special case for bool options
                value = value and (value[0] not in "0fFnN")  # ''/0/false/no are false, everything else is true
            elif isinstance(value, str) and t in (list, tuple, dict):
                import ast
                value = ast.literal_eval(value)
                if not isinstance(value, t):
                    vd.fail(f'error parsing string for `{optname}` into {t}')
            elif type(value) is t:    # if right type, no conversion
                pass
            elif curval is None:  # if None, do not apply type conversion
                pass
            else:
                value = t(value)

            if cmdlog and curval != value and self._get(optname, 'default').replayable:
                if obj != 'default' and type(obj) is not type:  # default and class options set on init aren't recorded
                    if vd.cmdlog:
                        self.add_option_to_cmdlogs(obj, optname, value, 'set-option')
        else:
            curval = None
            vd.warning(f'setting unknown option `{optname}`')
            module = 'unknown'

        return self._set(optname, value, obj, module=module)

    def unset(self, optname, obj=None):
        'Remove setting value for given context.'
        v = self._opts.unset(optname, obj)
        opt = self._get(optname)
        if vd.cmdlog and opt and opt.replayable:
            self.add_option_to_cmdlogs(obj, optname, value='', longname='unset-option')
        self._cache.clear()  # invalidate entire cache on any change
        return v

    def add_option_to_cmdlogs(self, obj, optname, value='', longname='set-option'):
        'Records option-set on cmdlogs'
        objname = self._opts.objname(obj)
        # all options are recorded on global cmdlog
        vd.cmdlog.addRow(vd.cmdlog.newRow(sheet=objname, row=optname,
                    keystrokes='', input=str(value),
                    longname=longname, undofuncs=[]))
        # global options are recorded on all cmdlog_sheet's
        if obj == 'global':
            for vs in vd.sheets:
                vs.cmdlog_sheet.addRow(vd.cmdlog.newRow(sheet=objname, row=optname,
                            keystrokes='', input=str(value),
                            longname=longname, undofuncs=[]))
        # sheet-specific options are recorded on that sheet
        elif isinstance(obj, BaseSheet):
            obj.cmdlog_sheet.addRow(vd.cmdlog.newRow(sheet=objname, row=optname,
                        keystrokes='', input=str(value),
                        longname=longname, undofuncs=[]))

    def resetToDefaults(self):
        'Remove all non-default option settings.'
        self._opts.resetToDefaults()
        self._cache.clear()

    def setdefault(self, optname, value, helpstr, module):
        return self._set(optname, value, 'default', helpstr=helpstr, module=module)

    def getall(self, prefix=''):
        'Return dictionary of all options beginning with `prefix` (with `prefix` removed from the name).'
        return { optname[len(prefix):] : self[optname]
                    for optname in self.keys()
                        if optname.startswith(prefix) }

    def __getattr__(self, optname):      # options.foo
        'Return value of option `optname` for stored options context.'
        if optname.startswith('_'):  # dunder probes (copy, pickle, hasattr)
            raise AttributeError(optname)
        return self[optname]

    def __setattr__(self, optname, value):   # options.foo = value
        'Set *value* of option *optname* for stored options context.'
        self.__setitem__(optname, value)

    def __getitem__(self, optname):      # options[optname]
        opt = self._get(optname, obj=self._obj)
        if not opt:
            return self._warn_unknown(optname)
        return opt.value

    def __setitem__(self, optname, value):   # options[optname] = value
        self.set(optname, value, obj=self._obj)


vd.commands = SettingsMgr()
vd.bindkeys = SettingsMgr()
vd._options = SettingsMgr()

vd.options = vd.OptionsObject(vd._options)  # global option settings
vd.option_aliases = {}


@VisiData.api
def optalias(vd, altname, optname, val=None):
    'Create an alias `altname` for option `optname`, setting the value to a particular `val` (if not None).'
    vd.option_aliases[altname] = (optname, val)


@VisiData.api
def _resolve_optalias(vd, optname, optval):
    while optname in vd.option_aliases:
        optname, v = vd.option_aliases.get(optname)
        if v is not None:  # value might be given
            optval = v

    return optname, optval


[docs] @VisiData.api def option(vd, name, default, description, replay=False, sheettype=BaseSheet, help:str='', cli_only=False): '''Declare a new option. - `name`: name of option - `default`: default value when no other override exists - `description`: short description of option (as shown in the **Options Sheet**) - `replay`: ``True`` if changes to the option should be stored in the **Command Log** - `sheettype`: ``None`` if the option is not sheet-specific, to make it global on CLI - `help`: extra help for the option (shown in the sidebar while editing) - `cli_only`: ``True`` if the option is only meaningful as a CLI argument (hidden from Options Sheet) ''' opt = vd.options.setdefault(name, default, description, vd.importingModule) opt.replayable = replay opt.sheettype=sheettype opt.extrahelp = help opt.cli_only = cli_only return opt
@VisiData.api def theme_option(vd, name, *args, **kwargs): if name.startswith('color_'): kwargs.setdefault('help', vd.help_color) return vd.option(name, *args, **kwargs)
[docs] @BaseSheet.class_api @classmethod def addCommand(cls, keystrokes, longname, execstr, helpstr='', replay=True, **kwargs): '''Add a new command to *cls* sheet type. - *keystrokes*: default keybinding, including **prefixes**. - *longname*: name of the command. - *execstr*: Python statement to pass to `exec()`'ed when the command is executed. - *helpstr*: help string shown in the **Commands Sheet**. ''' cmd = Command(longname, execstr, helpstr=helpstr, module=vd.importingModule, replay=replay, **kwargs) vd.commands.set(longname, cmd, cls) if keystrokes: vd.bindkey(keystrokes, longname, cls) return longname
@BaseSheet.class_api @classmethod def removeCommand(cls, keystrokes, longname): '''Remove a command from *cls* sheet type. - *keystrokes*: if provided, unbind this specific keystroke. - *longname*: name of the command to remove. ''' vd.commands.unset(longname, cls) if keystrokes: vd.unbindkey(keystrokes, cls) def _command(cls, binding, longname, helpstr, **kwargs): def decorator(func): funcname = longname.replace('-', '_') setattr(vd, funcname, func) cls.addCommand(binding, longname, f'vd.{funcname}(sheet)', helpstr, **kwargs) return decorator BaseSheet.command = classmethod(_command) globalCommand = BaseSheet.addCommand # to be deprecated @VisiData.api def bindkey(vd, keystrokes, longname, obj='BaseSheet'): 'Bind *keystrokes* to *longname* on BaseSheet and unbind more-specific bindings of keystrokes.' vd.bindkeys.set(vd.prettykeys(keystrokes.replace(' ', '')), longname, obj) @VisiData.api def unbindkey(vd, keystrokes, obj='BaseSheet'): 'Bind *keystrokes* to *longname* on BaseSheet and unbind more-specific bindings of keystrokes.' vd.bindkeys.unset(vd.prettykeys(keystrokes.replace(' ', '')), obj)
[docs] @BaseSheet.class_api @classmethod def bindkey(cls, keystrokes, longname): 'Bind *keystrokes* to *longname* on the *cls* sheet type.' oldlongname = vd.bindkeys._get(keystrokes, cls) if oldlongname and oldlongname != longname: vd.warning(f'`{keystrokes}` was already bound to `{oldlongname}`') vd.bindkey(keystrokes, longname, cls)
@BaseSheet.class_api @classmethod def unbindkey(cls, keystrokes): '''Unbind `keystrokes` on a `<SheetType>`. May be necessary to avoid a warning when overriding a binding on the same exact class.''' vd.unbindkey(keystrokes, cls) @BaseSheet.api def getCommand(sheet, cmd): 'Return the Command for the given *cmd*, which may be keystrokes, longname, or a Command itself, within the context of `sheet`.' if isinstance(cmd, Command): return cmd longname = cmd seen = [] while vd.bindkeys._get(longname, obj=sheet) is not None: longname = vd.bindkeys._get(longname, obj=sheet) if longname in seen: vd.fail(f'keystroke/command definitions form a cycle: {longname}') seen.append(longname) return vd.commands._get(longname, obj=sheet) @VisiData.api def loadConfigFile(vd, fn=''): p = visidata.Path(fn or vd.options.config) if p.exists(): newdefs = {} try: with open(p) as fd: code = compile(fd.read(), str(p), 'exec') vd.importingModule = 'visidatarc' exec(code, vd.getGlobals(), newdefs) except Exception as e: vd.exceptionCaught(e) finally: vd.importingModule = None keys = newdefs.get('__all__', None) if keys: vd.addGlobals({k:newdefs[k] for k in keys}) else: vd.addGlobals(newdefs) @VisiData.cached_property def config_file(vd): xdg_config_file = visidata.Path(user_config_dir('visidata')) / 'config.py' if xdg_config_file.exists(): return xdg_config_file else: return visidata.Path('~/.visidatarc') @VisiData.cached_property def cache_dir(vd): return visidata.Path(user_cache_dir('visidata')) @VisiData.cached_property def data_dir(vd): return visidata.Path(user_data_dir('visidata')) @VisiData.api def loadConfigAndPlugins(vd, args=AttrDict()): # set visidata_dir and config manually before loading config file, so visidata_dir can be set from cli or from $VD_DIR vd.options.visidata_dir = args.visidata_dir if args.visidata_dir is not None else os.getenv('VD_DIR', '') or vd.options.visidata_dir vd.options.config = args.config if args.config is not None else os.getenv('VD_CONFIG', '') or vd.options.config sys.path.append(str(visidata.Path(vd.options.visidata_dir))) sys.path.append(str(visidata.Path(vd.options.visidata_dir)/"plugins-deps")) # autoload installed plugins first args_plugins_autoload = args.plugins_autoload if 'plugins_autoload' in args else True if not args.nothing and args_plugins_autoload and vd.options.plugins_autoload: from importlib.metadata import entry_points eps_visidata = [] try: eps = entry_points() vp = 'visidata.plugins' if hasattr(eps, 'groups'): #Python >= 3.10 eps_visidata = eps.select(group=vp) else: #Python < 3.10 eps_visidata = eps.get(vp, []) except Exception as e: vd.warning(f'plugin autoload failed; see issue #1529: {e}') for ep in eps_visidata: try: vd.importingModule = ep.name plug = ep.load() sys.modules[f'visidata.plugins.{ep.name}'] = plug vd.debug(f'Plugin {ep.name} loaded') except Exception as e: vd.warning(f'plugin `{ep.name}` failed to load') vd.exceptionCaught(e) finally: vd.importingModule = None # import plugins from .visidata/plugins before .visidatarc, so plugin options can be overridden for modname in (args.imports or vd.options.imports or '').split(): try: vd.addGlobals(importlib.import_module(modname).__dict__) except ModuleNotFoundError as e: #1131 if 'plugins' in e.args[0]: continue vd.exceptionCaught(e) except Exception as e: vd.exceptionCaught(e) continue # user customisations in config file in standard location if vd.options.config: vd.loadConfigFile(vd.options.config) @VisiData.api def importModule(vd, pkgname, symbols=[]): 'Import the given *pkgname*, setting vd.importingModule to *pkgname* before import and resetting to None after.' modparts = pkgname.split('.') vd.importingModule = modparts[-1] r = importlib.import_module(pkgname) vd.importingModule = None vd.importedModules.append(r) vd.addGlobals({pkgname:r}) if symbols: vd.addGlobals({k:getattr(r, k) for k in symbols if hasattr(r, k)}) return r @VisiData.api def importSubmodules(vd, pkgname): 'Import all files below the given *pkgname*' import pkgutil m = vd.importModule(pkgname) for module in pkgutil.walk_packages(m.__path__): vd.importModule(pkgname + '.' + module.name)
[docs] @VisiData.api def importExternal(vd, modname, pipmodname=''): pipmodname = pipmodname or modname try: m = importlib.import_module(modname) vd.addGlobals({modname:m}) return m except ModuleNotFoundError: vd.fail(f'package `{modname}` not installed; run: `pip install {pipmodname}`')
@VisiData.api def requireOptions(vd, *args, help=''): '''Prompt user to input values for option names in *args* if current values are non-false. Offer to persist the values to visidatarc.''' optvals = {} for optname in args: if not getattr(vd.options, optname): if help: vd.status(help) v = vd.input(f'{optname}: ', record=False, display='password' not in optname) optvals[optname] = v vd.setPersistentOptions(**optvals) @VisiData.api def setPersistentOptions(vd, **kwargs): '''Set options from *kwargs* and offer to save them to visidatarc.''' for optname, optval in kwargs.items(): setattr(vd.options, optname, optval) optnames = ' '.join(kwargs.keys()) yn = vd.input(f'Save {len(kwargs)} options ({optnames}) to {vd.options.config}? ', record=False)[0:1] if yn and yn in 'Yy': with open(str(visidata.Path(vd.options.config)), mode='a') as fp: for optname, optval in kwargs.items(): fp.write(f'options.{optname}={repr(optval)}\n') vd.option('visidata_dir', user_config_dir('visidata'), 'VisiData directory for plugins and other files; set with $VD_DIR or --visidata-dir', sheettype=None) BaseSheet.bindkey('^M', '^J') # for windows ENTER vd.addGlobals({ 'options': vd.options, # legacy 'globalCommand': BaseSheet.addCommand, 'Option': Option, })