about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authortoonn <toonn@toonn.io>2019-09-26 11:47:39 +0200
committertoonn <toonn@toonn.io>2019-09-27 14:42:45 +0200
commitf6fa67646d72b3e1872a9b64633018dde93a698a (patch)
treed3a483bf1a87cb4a72b26474150c29a8ae80914a
parent2c33cedaaac2a21d538f20fcb6fc36e23467a79d (diff)
downloadranger-f6fa67646d72b3e1872a9b64633018dde93a698a.tar.gz
Add MacroDict
Macros are resolved for set commands. A contributor wanted to add a
setting for a date format string to be used in ranger's status bar. The
string they tried happened to include `%d` which is a valid ranger macro
for the name of `thisdir`. This attribute is not yet defined when ranger
is reading `rc.conf` and this uncaught exception would crash ranger.

To work around this we need to catch `AttributeError`s for values that
might not exist yet. To avoid the repetition that'd come with all these
try-catch statements that behavior has been encapsulated in a new
MacroDict. This is almost as easy to use as the regular python
dictionary used previously. The only difference being that we need to
wrap values that might cause problems in a python `lambda`, though it
doesn't do any harm to wrap values that can't raise exceptions.
-rw-r--r--ranger/core/actions.py106
-rw-r--r--ranger/ext/macrodict.py81
2 files changed, 120 insertions, 67 deletions
diff --git a/ranger/core/actions.py b/ranger/core/actions.py
index 00394be5..2c7f3b9a 100644
--- a/ranger/core/actions.py
+++ b/ranger/core/actions.py
@@ -21,21 +21,20 @@ from sys import version_info
 from logging import getLogger
 
 import ranger
+from ranger.container.directory import Directory
+from ranger.container.file import File
+from ranger.container.settings import ALLOWED_SETTINGS, ALLOWED_VALUES
+from ranger.core.loader import CommandLoader, CopyLoader
+from ranger.core.shared import FileManagerAware, SettingsAware
+from ranger.core.tab import Tab
 from ranger.ext.direction import Direction
-from ranger.ext.relative_symlink import relative_symlink
 from ranger.ext.keybinding_parser import key_to_string, construct_keybinding
-from ranger.ext.shell_escape import shell_quote
+from ranger.ext.macrodict import MacroDict, MACRO_FAIL, macro_val
 from ranger.ext.next_available_filename import next_available_filename
+from ranger.ext.relative_symlink import relative_symlink
 from ranger.ext.rifle import squash_flags, ASK_COMMAND
-from ranger.core.shared import FileManagerAware, SettingsAware
-from ranger.core.tab import Tab
-from ranger.container.directory import Directory
-from ranger.container.file import File
-from ranger.core.loader import CommandLoader, CopyLoader
-from ranger.container.settings import ALLOWED_SETTINGS, ALLOWED_VALUES
-
+from ranger.ext.shell_escape import shell_quote
 
-MACRO_FAIL = "<\x01\x01MACRO_HAS_NO_VALUE\x01\01>"
 
 LOG = getLogger(__name__)
 
@@ -294,8 +293,8 @@ class Actions(  # pylint: disable=too-many-instance-attributes,too-many-public-m
             raise ValueError("Could not apply macros to `%s'" % string)
         return result
 
-    def get_macros(self):  # pylint: disable=too-many-branches,too-many-statements
-        macros = {}
+    def get_macros(self):
+        macros = MacroDict()
 
         macros['rangerdir'] = ranger.RANGERDIR
         if not ranger.args.clean:
@@ -303,57 +302,39 @@ class Actions(  # pylint: disable=too-many-instance-attributes,too-many-public-m
             macros['datadir'] = self.fm.datapath()
         macros['space'] = ' '
 
-        try:
-            macros['f'] = self.fm.thisfile.relative_path
-        except AttributeError:
-            macros['f'] = MACRO_FAIL
+        macros['f'] = lambda: self.fm.thisfile.relative_path
 
-        if self.fm.thistab.get_selection:
-            macros['p'] = [os.path.join(self.fm.thisdir.path, fl.relative_path)
-                           for fl in self.fm.thistab.get_selection()]
-            macros['s'] = [fl.relative_path for fl in self.fm.thistab.get_selection()]
-        else:
-            macros['p'] = MACRO_FAIL
-            macros['s'] = MACRO_FAIL
+        macros['p'] = lambda: [os.path.join(self.fm.thisdir.path,
+                                            fl.relative_path) for fl in
+                               self.fm.thistab.get_selection()]
+        macros['s'] = lambda: [fl.relative_path for fl in
+                               self.fm.thistab.get_selection()]
 
-        if self.fm.copy_buffer:
-            macros['c'] = [fl.path for fl in self.fm.copy_buffer]
-        else:
-            macros['c'] = MACRO_FAIL
+        macros['c'] = lambda: [fl.path for fl in self.fm.copy_buffer]
 
-        if self.fm.thisdir.files:
-            macros['t'] = [fl.relative_path for fl in self.fm.thisdir.files
-                           if fl.realpath in self.fm.tags or []]
-        else:
-            macros['t'] = MACRO_FAIL
+        macros['t'] = lambda: [fl.relative_path for fl in
+                               self.fm.thisdir.files if fl.realpath in
+                               self.fm.tags or []]
 
-        if self.fm.thisdir:
-            macros['d'] = self.fm.thisdir.path
-        else:
-            macros['d'] = '.'
+        macros['d'] = macro_val(lambda: self.fm.thisdir.path, fallback='.')
 
         # define d/f/p/s macros for each tab
         for i in range(1, 10):
+            # pylint: disable=cell-var-from-loop
             try:
                 tab = self.fm.tabs[i]
-            except KeyError:
+                tabdir = tab.thisdir
+            except (KeyError, AttributeError):
                 continue
-            tabdir = tab.thisdir
             if not tabdir:
                 continue
             i = str(i)
-            macros[i + 'd'] = tabdir.path
-            if tabdir.get_selection():
-                macros[i + 'p'] = [os.path.join(tabdir.path, fl.relative_path)
-                                   for fl in tabdir.get_selection()]
-                macros[i + 's'] = [fl.path for fl in tabdir.get_selection()]
-            else:
-                macros[i + 'p'] = MACRO_FAIL
-                macros[i + 's'] = MACRO_FAIL
-            if tabdir.pointed_obj:
-                macros[i + 'f'] = tabdir.pointed_obj.path
-            else:
-                macros[i + 'f'] = MACRO_FAIL
+            macros[i + 'd'] = lambda: tabdir.path
+            macros[i + 'p'] = lambda: [os.path.join(tabdir.path,
+                                                    fl.relative_path) for fl in
+                                       tabdir.get_selection()]
+            macros[i + 's'] = lambda: [fl.path for fl in tabdir.get_selection()]
+            macros[i + 'f'] = lambda: tabdir.pointed_obj.path
 
         # define D/F/P/S for the next tab
         found_current_tab = False
@@ -369,25 +350,16 @@ class Actions(  # pylint: disable=too-many-instance-attributes,too-many-public-m
                 found_current_tab = True
         if found_current_tab and next_tab is None:
             next_tab = self.fm.tabs[first_tab]
-        next_tab_dir = next_tab.thisdir
+        try:
+            next_tab_dir = next_tab.thisdir
+        except AttributeError:
+            return macros
 
-        if next_tab_dir:
-            macros['D'] = str(next_tab_dir.path)
-            if next_tab.thisfile:
-                macros['F'] = next_tab.thisfile.path
-            else:
-                macros['F'] = MACRO_FAIL
-            if next_tab_dir.get_selection():
-                macros['P'] = [os.path.join(next_tab.path, fl.path)
+        macros['D'] = lambda: str(next_tab_dir.path)
+        macros['F'] = lambda: next_tab.thisfile.path
+        macros['P'] = lambda: [os.path.join(next_tab.path, fl.path)
                                for fl in next_tab.get_selection()]
-                macros['S'] = [fl.path for fl in next_tab.get_selection()]
-            else:
-                macros['P'] = MACRO_FAIL
-                macros['S'] = MACRO_FAIL
-        else:
-            macros['D'] = MACRO_FAIL
-            macros['F'] = MACRO_FAIL
-            macros['S'] = MACRO_FAIL
+        macros['S'] = lambda: [fl.path for fl in next_tab.get_selection()]
 
         return macros
 
diff --git a/ranger/ext/macrodict.py b/ranger/ext/macrodict.py
new file mode 100644
index 00000000..8a8a4a3d
--- /dev/null
+++ b/ranger/ext/macrodict.py
@@ -0,0 +1,81 @@
+import sys
+
+
+MACRO_FAIL = "<\x01\x01MACRO_HAS_NO_VALUE\x01\01>"
+
+
+def macro_val(thunk, fallback=MACRO_FAIL):
+    try:
+        return thunk()
+    except AttributeError:
+        return fallback
+
+
+try:
+    from collections.abc import MutableMapping  # pylint: disable=no-name-in-module
+except ImportError:
+    from collections import MutableMapping
+
+
+class MacroDict(MutableMapping):
+    """Mapping that returns a fallback value when thunks error
+
+    Macros can be used in scenarios where several attributes aren't
+    initialized yet. To avoid errors in these cases we have to delay the
+    evaluation of these attributes using ``lambda``s. This
+    ``MutableMapping`` evaluates these thunks before returning them
+    replacing them with a fallback value if necessary.
+
+    For convenience it also catches ``TypeError`` so you can store
+    non-callable values without thunking.
+
+    >>> m = MacroDict()
+    >>> o = type("", (object,), {})()
+    >>> o.existing_attribute = "I exist!"
+
+    >>> m['a'] = "plain value"
+    >>> m['b'] = lambda: o.non_existent_attribute
+    >>> m['c'] = lambda: o.existing_attribute
+
+    >>> m['a']
+    'plain value'
+    >>> m['b']
+    '<\\x01\\x01MACRO_HAS_NO_VALUE\\x01\\x01>'
+    >>> m['c']
+    'I exist!'
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(MacroDict, self).__init__()
+        self.__dict__.update(*args, **kwargs)
+
+    def __setitem__(self, key, value):
+        try:
+            real_val = value()
+            if real_val is None:
+                real_val = MACRO_FAIL
+        except AttributeError:
+            real_val = MACRO_FAIL
+        except TypeError:
+            real_val = value
+        self.__dict__[key] = real_val
+
+    def __getitem__(self, key):
+        return self.__dict__[key]
+
+    def __delitem__(self, key):
+        del self.__dict__[key]
+
+    def __iter__(self):
+        return iter(self.__dict__)
+
+    def __len__(self):
+        return len(self.__dict__)
+
+    def __str__(self):
+        return str(self.__dict__)
+
+
+if __name__ == '__main__':
+    import doctest
+    sys.exit(doctest.testmod()[0])