Repository: pyout/pyout

Issue:
Allow to specify in the style to humanize the value
E.g. as datalad is using for sizes.  Could also be time, etc.  I think the easiest would be just to allow callbacks as to how to format the value (`format_value` attribute? may be it is already implemented?), since humanize provides all those humanize functions:
```shell
$> python -c 'import humanize; print(dir(humanize))'
['Fraction', 'N_', 'P_', 'VERSION', '__all__', '__builtins__', '__doc__', '__file__', '__name__', '__package__', '__path__', 'activate', 'apnumber', 'compat', 'deactivate', 'filesize', 'fractional', 'human_powers', 'i18n', 'intcomma', 'intword', 'naturaldate', 'naturalday', 'naturaldelta', 'naturalsize', 'naturaltime', 'number', 'ordinal', 'powers', 're', 'suffixes', 'time', 'x']
```
  

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bfdb874..ac34470 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
 TODO Summary
 
 ### Added
+
+- A new style attribute, "transform", has been added to the schema.
+
+  This feature can be used to provide a function that takes a field
+  value and returns a transformed field value.
+
 ### Changed
 ### Deprecated
 ### Fixed
diff --git a/pyout/elements.py b/pyout/elements.py
index 628c16d..160a1f8 100644
--- a/pyout/elements.py
+++ b/pyout/elements.py
@@ -11,7 +11,7 @@ schema = {
             "type": "string",
             "enum": ["left", "right", "center"],
             "default": "left",
-            "scope": "table"},
+            "scope": "column"},
         "bold": {
             "description": "Whether text is bold",
             "oneOf": [{"type": "boolean"},
@@ -46,12 +46,13 @@ schema = {
                            "max": {"type": ["integer", "null"]},
                            "min": {"type": ["integer", "null"]}}}],
             "default": "auto",
-            "scope": "table"},
+            "scope": "column"},
         "styles": {
             "type": "object",
             "properties": {"align": {"$ref": "#/definitions/align"},
                            "bold": {"$ref": "#/definitions/bold"},
                            "color": {"$ref": "#/definitions/color"},
+                           "transform": {"$ref": "#/definitions/transform"},
                            "underline": {"$ref": "#/definitions/underline"},
                            "width": {"$ref": "#/definitions/width"}},
             "additionalProperties": False},
@@ -72,7 +73,14 @@ schema = {
             "description": "Map a value to a style",
             "type": "object",
             "properties": {"label": {"type": "object"}},
-            "additionalProperties": False}
+            "additionalProperties": False},
+        "transform": {
+            "description": """An arbitrary function.
+            This function will be called with the (unprocessed) field
+            value as the single argument and should return a
+            transformed value.  Note: This function should not have
+            side-effects because it may be called multiple times.""",
+            "scope": "field"}
     },
     "type": "object",
     "properties": {
diff --git a/pyout/field.py b/pyout/field.py
index 8e81204..9dee4fc 100644
--- a/pyout/field.py
+++ b/pyout/field.py
@@ -1,48 +1,99 @@
 """Define a "field" based on a sequence of processor functions.
 """
+from itertools import chain
+from collections import defaultdict
 
 
 class Field(object):
-    """Format, process, and render tabular fields.
+    """Render values based on a list of processors.
 
     A Field instance is a template for a string that is defined by its
-    width, text alignment, and its "processors".  When a field is
-    called with a value, it renders the value as a string with the
-    specified width and text alignment.  Before this string is
-    returned, it is passed through the chain of processors.  The
-    rendered string is the result returned by the last processor.
+    width, text alignment, and its "processors".
+
+    When a field is called with a value, the value is rendered in
+    three steps.
+
+                       pre -> format -> post
+
+    During the first step, the value is fed through the list of
+    pre-format processor functions.  The result of this value is then
+    formatted as a string with the specified width and alignment.
+    Finally, this result is fed through the list of the post-format
+    processors.  The rendered string is the result returned by the
+    last processor
 
     Parameters
     ----------
-    width : int
-    align : {'left', 'right', 'center'}
+    width : int, optional
+    align : {'left', 'right', 'center'}, optional
+    default_keys, other_keys : sequence, optional
+        Together, these define the "registered" set of processor keys
+        that can be used in the `pre` and `post` dicts.  Any key given
+        to the `add` method or instance call must be contained in one
+        of these collections.
+
+        The processor lists for `default_keys` is used when the
+        instance is called without a list of `keys`.  `other_keys`
+        defines additional keys that can be passed as the `keys`
+        argument to the instance call.
 
     Attributes
     ----------
     width : int
-    align : str
-    processors : dict
-        Each key maps to a list of processors.  The keys "core" and
-        "default" must always be present.  When an instance object is
-        called, the rendered result is always sent through the "core"
-        processors.  It will then be sent through the "default"
-        processors unless another key is provided as the optional
-        `which` argument.
-
-        A processor should take two positional arguments, the value
-        that is being rendered and the current result.  Its return
-        value will be passed to the next processor as the current
-        result.
+    registered_keys : set
+        Set of available keys.
+    default_keys : list
+        Defines which processor lists are called by default and in
+        what order.  The values can be overridden by the `keys`
+        argument when calling the instance.
+    pre, post : dict of lists
+        These map each registered key to a list of processors.
+        Conceptually, `pre` and `post` are a list of functions that
+        form a pipeline, but they are structured as a dict of lists to
+        allow different processors to be grouped by key.  By
+        specifying keys, the caller can control which groups are
+        "enabled".
     """
 
     _align_values = {"left": "<", "right": ">", "center": "^"}
 
-    def __init__(self, width=10, align="left"):
+    def __init__(self, width=10, align="left",
+                 default_keys=None, other_keys=None):
         self._width = width
         self._align = align
         self._fmt = self._build_format()
 
-        self.processors = {"core": [], "default": []}
+        self.default_keys = default_keys or []
+        self.registered_keys = set(chain(self.default_keys, other_keys or []))
+
+        self.pre = defaultdict(list)
+        self.post = defaultdict(list)
+
+    def _check_if_registered(self, key):
+        if key not in self.registered_keys:
+            raise ValueError(
+                "key '{}' was not specified at initialization".format(key))
+
+    def add(self, kind, key, *values):
+        """Add processor functions.
+
+        Parameters
+        ----------
+        kind : {"pre", "post"}
+        key : str
+            A registered key.  Add the functions (in order) to this
+            key's list of processors.
+        *values : callables
+            Processors to add.
+        """
+        if kind == "pre":
+            procs = self.pre
+        elif kind == "post":
+            procs = self.post
+        else:
+            raise ValueError("kind is not 'pre' or 'post'")
+        self._check_if_registered(key)
+        procs[key].extend(values)
 
     @property
     def width(self):
@@ -57,22 +108,52 @@ class Field(object):
         align = self._align_values[self._align]
         return "".join(["{:", align, str(self.width), "}"])
 
-    def __call__(self, value, which="default"):
+    def _format(self, _, result):
+        """Wrap format call as a two-argument processor function.
+        """
+        return self._fmt.format(result)
+
+    def __call__(self, value, keys=None, exclude_post=False):
         """Render `value` by feeding it through the processors.
 
         Parameters
         ----------
         value : str
-        which : str, optional
-            A key for the `processors` attribute that indicates the
-            list of processors to use in addition to the "core" list.
+        keys : sequence, optional
+            These lists define which processor lists are called and in
+            what order.  If not specified, the `default_keys`
+            attribute will be used.
+        exclude_post : bool, optional
+            Whether to return the vaue after the format step rather
+            than feeding it through post-format processors.
         """
-        result = self._fmt.format(value)
-        for fn in self.processors["core"] + self.processors[which]:
+        if keys is None:
+            keys = self.default_keys
+        for key in keys:
+            self._check_if_registered(key)
+
+        pre_funcs = chain(*(self.pre[k] for k in keys))
+        if exclude_post:
+            post_funcs = []
+        else:
+            post_funcs = chain(*(self.post[k] for k in keys))
+
+        funcs = chain(pre_funcs, [self._format], post_funcs)
+        result = value
+        for fn in funcs:
             result = fn(value, result)
         return result
 
 
+class StyleFunctionError(Exception):
+    """Signal that a style function failed.
+    """
+    def __init__(self, function):
+        msg = ("Style transform {} raised an exception. "
+               "See above.".format(function))
+        super(StyleFunctionError, self).__init__(msg)
+
+
 class StyleProcessors(object):
     """A base class for generating Field.processors for styled output.
 
@@ -138,6 +219,17 @@ class StyleProcessors(object):
             return result[:length]
         return truncate_fn
 
+    @staticmethod
+    def transform(function):
+        """Return a processor for a style's "transform" function.
+        """
+        def transform_fn(_, result):
+            try:
+                return function(result)
+            except:
+                raise StyleFunctionError(function)
+        return transform_fn
+
     def by_key(self, key):
         """Return a processor for the style given by `key`.
 
@@ -234,8 +326,24 @@ class StyleProcessors(object):
             return keys[0]
         raise ValueError("Type of `value` could not be determined")
 
-    def from_style(self, column_style):
-        """Yield processors based on `column_style`.
+    def pre_from_style(self, column_style):
+        """Yield pre-format processors based on `column_style`.
+
+        Parameters
+        ----------
+        column_style : dict
+            A style where the top-level keys correspond to style
+            attributes such as "bold" or "color".
+
+        Returns
+        -------
+        A generator object.
+        """
+        if "transform" in column_style:
+            yield self.transform(column_style["transform"])
+
+    def post_from_style(self, column_style):
+        """Yield post-format processors based on `column_style`.
 
         Parameters
         ----------
diff --git a/pyout/tabular.py b/pyout/tabular.py
index f33549c..d205a79 100644
--- a/pyout/tabular.py
+++ b/pyout/tabular.py
@@ -47,10 +47,10 @@ class TermProcessors(StyleProcessors):
             return result
         return maybe_reset_fn
 
-    def from_style(self, column_style):
-        """Call StyleProcessors.from_style, adding a Terminal-specific reset.
+    def post_from_style(self, column_style):
+        """A Terminal-specific reset to StyleProcessors.post_from_style.
         """
-        for proc in super(TermProcessors, self).from_style(column_style):
+        for proc in super(TermProcessors, self).post_from_style(column_style):
             yield proc
         yield self._maybe_reset()
 
@@ -175,7 +175,7 @@ class Tabular(object):
         for column in self._columns:
             cstyle = self._style[column]
 
-            procs = []
+            core_procs = []
             style_width = cstyle["width"]
             is_auto = style_width == "auto" or _safe_get(style_width, "auto")
 
@@ -187,17 +187,26 @@ class Tabular(object):
 
                 if wmax is not None:
                     marker = _safe_get(style_width, "marker", True)
-                    procs = [self._tproc.truncate(wmax, marker)]
+                    core_procs = [self._tproc.truncate(wmax, marker)]
             elif is_auto is False:
                 raise ValueError("No 'width' specified")
             else:
                 width = style_width
-                procs = [self._tproc.truncate(width)]
-
-            field = Field(width=width, align=cstyle["align"])
-            field.processors["core"] = procs
-            field.processors["default"] = list(self._tproc.from_style(cstyle))
-
+                core_procs = [self._tproc.truncate(width)]
+
+            # We are creating a distinction between "core" processors,
+            # that we always want to be active and "default"
+            # processors that we want to be active unless there's an
+            # overriding style (i.e., a header is being written or the
+            # `style` argument to __call__ is specified).
+            field = Field(width=width, align=cstyle["align"],
+                          default_keys=["core", "default"],
+                          other_keys=["override"])
+            field.add("pre", "default",
+                      *(self._tproc.pre_from_style(cstyle)))
+            field.add("post", "core", *core_procs)
+            field.add("post", "default",
+                      *(self._tproc.post_from_style(cstyle)))
             self._fields[column] = field
 
     @property
@@ -233,12 +242,15 @@ class Tabular(object):
             return self._seq_to_dict
         return self._attrs_to_dict
 
-    def _set_widths(self, row):
+    def _set_widths(self, row, proc_group):
         """Update auto-width Fields based on `row`.
 
         Parameters
         ----------
         row : dict
+        proc_group : {'default', 'override'}
+            Whether to consider 'default' or 'override' key for pre-
+            and post-format processors.
 
         Raises
         ------
@@ -248,12 +260,21 @@ class Tabular(object):
         rewrite = False
         for column in self._columns:
             if column in self._autowidth_columns:
-                value_width = len(str(row[column]))
+                field = self._fields[column]
+                # If we've added style transform function as
+                # pre-format processors, we want to measure the width
+                # of their result rather than the raw value.
+                if field.pre[proc_group]:
+                    value = field(row[column], keys=[proc_group],
+                                  exclude_post=True)
+                else:
+                    value = row[column]
+                value_width = len(str(value))
                 wmax = self._autowidth_columns[column]["max"]
-                if value_width > self._fields[column].width:
-                    if wmax is None or self._fields[column].width < wmax:
+                if value_width > field.width:
+                    if wmax is None or field.width < wmax:
                         rewrite = True
-                    self._fields[column].width = value_width
+                    field.width = value_width
         if rewrite:
             raise RewritePrevious
 
@@ -281,24 +302,76 @@ class Tabular(object):
             if self._lock:
                 self._lock.release()
 
-    def _writerow(self, row, style=None, adopt=True):
-        fields = self._fields
+    def _style_proc_group(self, style, adopt=True):
+        """Return whether group is "default" or "override".
 
+        In the case of "override", the self._fields pre-format and
+        post-format processors will be set under the "override" key.
+        """
+        fields = self._fields
         if style is not None:
+            if adopt:
+                style = elements.adopt(self._style, style)
             elements.validate(style)
 
-            rowstyle = elements.adopt(self._style, style) if adopt else style
             for column in self._columns:
-                fields[column].processors["row"] = list(
-                    self._tproc.from_style(rowstyle[column]))
-            proc_key = "row"
+                fields[column].add(
+                    "pre", "override",
+                    *(self._tproc.pre_from_style(style[column])))
+                fields[column].add(
+                    "post", "override",
+                    *(self._tproc.post_from_style(style[column])))
+            return "override"
         else:
-            proc_key = "default"
+            return "default"
 
-        proc_fields = [fields[c](row[c], proc_key) for c in self._columns]
+    def _writerow(self, row, proc_keys=None):
+        proc_fields = [self._fields[c](row[c], keys=proc_keys)
+                       for c in self._columns]
         self.term.stream.write(
             self._style["separator_"].join(proc_fields) + "\n")
 
+    def _check_and_write(self, row, style, adopt=True,
+                         repaint=True, no_write=False):
+        """Main internal entry point for writing a row.
+
+        Parameters
+        ----------
+        row : dict
+           Data to write.
+        style : dict or None
+            Overridding style or None.
+        adopt : bool, optional
+            If true, overlay `style` on top of `self._style`.
+            Otherwise, treat `style` as a standalone style.
+        repaint : bool, optional
+            Whether to repaint of width check reports that previous
+            widths are stale.
+        no_write : bool, optional
+            Do the check but don't write.  Instead, return the
+            processor keys that can be used to call self._writerow
+            directly.
+        """
+        repainted = False
+        proc_group = self._style_proc_group(style, adopt=adopt)
+        try:
+            self._set_widths(row, proc_group)
+        except RewritePrevious:
+            if repaint:
+                self._repaint()
+                repainted = True
+
+        if proc_group == "override":
+            # Override the "default" processor key.
+            proc_keys = ["core", "override"]
+        else:
+            # Use the set of processors defined by _setup_fields.
+            proc_keys = None
+
+        if no_write:
+            return proc_keys, repainted
+        self._writerow(row, proc_keys)
+
     def _maybe_write_header(self):
         if self._style["header_"] is None:
             return
@@ -310,13 +383,10 @@ class Tabular(object):
         else:
             row = dict(zip(self._columns, self._columns))
 
-        try:
-            self._set_widths(row)
-        except RewritePrevious:
-            # We're at the header, so there aren't any previous lines
-            # to update.
-            pass
-        self._writerow(row, style=self._style["header_"], adopt=False)
+        # Set repaint=False because we're at the header, so there
+        # aren't any previous lines to update.
+        self._check_and_write(row, self._style["header_"],
+                              adopt=False, repaint=False)
 
     @staticmethod
     def _strip_callables(row):
@@ -415,12 +485,7 @@ class Tabular(object):
         with self._write_lock():
             if not self._rows:
                 self._maybe_write_header()
-
-            try:
-                self._set_widths(row)
-            except RewritePrevious:
-                self._repaint()
-            self._writerow(row, style=style)
+            self._check_and_write(row, style)
             self._rows.append(row)
         if callables:
             self._start_callables(row, callables)
@@ -499,10 +564,10 @@ class Tabular(object):
             idx = len(self._rows) - nback
             self._rows[idx].update(values)
 
-            try:
-                self._set_widths(self._rows[idx])
-            except RewritePrevious:
-                self._repaint()
-            else:
+            # Set no_write=True because there is no reason to go back
+            # and rewrite row if we've already repainted.
+            keys, repainted = self._check_and_write(self._rows[idx], style,
+                                                    no_write=True)
+            if not repainted:
                 with self._moveback(nback):
-                    self._writerow(self._rows[idx], style)
+                    self._writerow(self._rows[idx], keys)


Test patch:
diff --git a/pyout/tests/test_field.py b/pyout/tests/test_field.py
index fd4209b..2a36ff1 100644
--- a/pyout/tests/test_field.py
+++ b/pyout/tests/test_field.py
@@ -15,17 +15,26 @@ def test_field_update():
 
 
 def test_field_processors():
-    field = Field(width=6, align="center")
+    def pre(_, result):
+        return result.upper()
 
-    def proc1(_, result):
+    def post1(_, result):
         return "AAA" + result
 
-    def proc2(_, result):
+    def post2(_, result):
         return result + "ZZZ"
 
-    field.processors["default"] = [proc1, proc2]
+    field = Field(width=6, align="center",
+                  default_keys=["some_key", "another_key"])
+    field.add("pre", "some_key", pre)
+    field.add("post", "another_key", *[post1, post2])
+    assert field("ok") == "AAA  OK  ZZZ"
 
-    assert field("ok") == "AAA  ok  ZZZ"
+    with pytest.raises(ValueError):
+        field.add("not pre or post", "k")
+
+    with pytest.raises(ValueError):
+        field.add("pre", "not registered key")
 
 
 def test_truncate_mark_true():
diff --git a/pyout/tests/test_tabular.py b/pyout/tests/test_tabular.py
index 873932b..ae8b507 100644
--- a/pyout/tests/test_tabular.py
+++ b/pyout/tests/test_tabular.py
@@ -9,6 +9,7 @@ from mock import patch
 import pytest
 
 from pyout import Tabular
+from pyout.field import StyleFunctionError
 
 # TestTerminal, unicode_cap, and unicode_parm are copied from
 # blessings' tests.
@@ -599,6 +600,85 @@ def test_tabular_write_intervals_bold():
     assert eq_repr(fd.getvalue(), expected)
 
 
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_transform():
+    fd = StringIO()
+    out = Tabular(style={"val": {"transform": lambda x: x[::-1]}},
+                  stream=fd)
+    out(OrderedDict([("name", "foo"),
+                     ("val", "330")]))
+    out(OrderedDict([("name", "bar"),
+                     ("val", "780")]))
+
+    expected = ("foo 033\n"
+                "bar 087\n")
+    assert eq_repr(fd.getvalue(), expected)
+
+
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_transform_with_header():
+    fd = StringIO()
+    out = Tabular(style={"header_": {},
+                         "name": {"width": 4},
+                         "val": {"transform": lambda x: x[::-1]}},
+                  stream=fd)
+    out(OrderedDict([("name", "foo"),
+                     ("val", "330")]))
+    out(OrderedDict([("name", "bar"),
+                     ("val", "780")]))
+
+    expected = ("name val\n"
+                "foo  033\n"
+                "bar  087\n")
+    assert eq_repr(fd.getvalue(), expected)
+
+
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_transform_autowidth():
+    fd = StringIO()
+    out = Tabular(style={"val": {"transform": lambda x: x * 2}},
+                  stream=fd)
+    out(OrderedDict([("name", "foo"),
+                     ("val", "330")]))
+    out(OrderedDict([("name", "bar"),
+                     ("val", "7800")]))
+
+    lines = fd.getvalue().splitlines()
+    assert len([ln for ln in lines if ln.endswith("foo 330330  ")]) == 1
+    assert len([ln for ln in lines if ln.endswith("bar 78007800")]) == 1
+
+
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_transform_on_header():
+    fd = StringIO()
+    out = Tabular(style={"header_": {"transform": str.upper},
+                         "name": {"width": 4},
+                         "val": {"width": 3}},
+                  stream=fd)
+    out(OrderedDict([("name", "foo"),
+                     ("val", "330")]))
+    out(OrderedDict([("name", "bar"),
+                     ("val", "780")]))
+
+    expected = ("NAME VAL\n"
+                "foo  330\n"
+                "bar  780\n")
+    assert eq_repr(fd.getvalue(), expected)
+
+
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_transform_func_error():
+    fd = StringIO()
+    out = Tabular(style={"name": {"width": 4},
+                         "val": {"transform": lambda x: x[::-1]}},
+                  stream=fd)
+    # The transform function receives the data as given, so it fails
+    # trying to index an integer.
+    with pytest.raises(StyleFunctionError):
+        out(OrderedDict([("name", "foo"),
+                         ("val", 330)]))
+
+
 @patch("pyout.tabular.Terminal", TestTerminal)
 def test_tabular_write_width_truncate_long():
     fd = StringIO()