Repository: pyout/pyout

Issue:
Allow for incomplete records
So it must not crash -- instead it should place some "default" or specified "absent" value (e.g. `""` or `"-"`)

Those values which were not provided should not be passed into the summary callables???  we might just provide `exclude_absent` decorator for summary transformers

Discussion:

Patch:
diff --git a/pyout/elements.py b/pyout/elements.py
index 160a1f8..700f53d 100644
--- a/pyout/elements.py
+++ b/pyout/elements.py
@@ -28,6 +28,12 @@ schema = {
                       {"$ref": "#/definitions/interval"}],
             "default": "black",
             "scope": "field"},
+        "missing": {
+            "description": "Text to display for missing values",
+            "type": "string",
+            "default": "",
+            "scope": "column"
+        },
         "underline": {
             "description": "Whether text is underlined",
             "oneOf": [{"type": "boolean"},
@@ -52,6 +58,7 @@ schema = {
             "properties": {"align": {"$ref": "#/definitions/align"},
                            "bold": {"$ref": "#/definitions/bold"},
                            "color": {"$ref": "#/definitions/color"},
+                           "missing": {"$ref": "#/definitions/missing"},
                            "transform": {"$ref": "#/definitions/transform"},
                            "underline": {"$ref": "#/definitions/underline"},
                            "width": {"$ref": "#/definitions/width"}},
diff --git a/pyout/field.py b/pyout/field.py
index 9dee4fc..bb20027 100644
--- a/pyout/field.py
+++ b/pyout/field.py
@@ -145,6 +145,42 @@ class Field(object):
         return result
 
 
+class Nothing(object):
+    """Internal class to represent missing values.
+
+    This is used instead of a built-ins like None, "", or 0 to allow
+    us to unambiguously identify a missing value.  In terms of
+    methods, it tries to mimic the string `text` (an empty string by
+    default) because that behavior is the most useful internally for
+    formatting the output.
+
+    Parameters
+    ----------
+    text : str, optional
+        Text to use for string representation of this object.
+    """
+
+    def __init__(self, text=""):
+        self._text = text
+
+    def __str__(self):
+        return self._text
+
+    def __add__(self, right):
+        return str(self) + right
+
+    def __radd__(self, left):
+        return left + str(self)
+
+    def __bool__(self):
+        return False
+
+    __nonzero__ = __bool__  # py2
+
+    def __format__(self, format_spec):
+        return str.__format__(self._text, format_spec)
+
+
 class StyleFunctionError(Exception):
     """Signal that a style function failed.
     """
@@ -224,6 +260,8 @@ class StyleProcessors(object):
         """Return a processor for a style's "transform" function.
         """
         def transform_fn(_, result):
+            if isinstance(result, Nothing):
+                return result
             try:
                 return function(result)
             except:
@@ -292,7 +330,11 @@ class StyleProcessors(object):
         A function.
         """
         def by_interval_lookup_fn(value, result):
-            value = float(value)
+            try:
+                value = float(value)
+            except TypeError:
+                return result
+
             for start, end, lookup_value in intervals:
                 if start is None:
                     start = float("-inf")
diff --git a/pyout/tabular.py b/pyout/tabular.py
index a63713f..ec16773 100644
--- a/pyout/tabular.py
+++ b/pyout/tabular.py
@@ -13,7 +13,9 @@ from multiprocessing.dummy import Pool
 from blessings import Terminal
 
 from pyout import elements
-from pyout.field import Field, StyleProcessors
+from pyout.field import Field, StyleProcessors, Nothing
+
+NOTHING = Nothing()
 
 
 class TermProcessors(StyleProcessors):
@@ -133,7 +135,7 @@ class Tabular(object):
 
         self._init_style = style
         self._style = None
-
+        self._nothings = {}  # column => missing value
         self._autowidth_columns = {}
 
         if columns is not None:
@@ -171,6 +173,12 @@ class Tabular(object):
 
         elements.validate(self._style)
 
+        for col in self._columns:
+            if "missing" in self._style[col]:
+                self._nothings[col] = Nothing(self._style[col]["missing"])
+            else:
+                self._nothings[col] = NOTHING
+
     def _setup_fields(self):
         self._fields = {}
         for column in self._columns:
@@ -181,7 +189,7 @@ class Tabular(object):
             is_auto = style_width == "auto" or _safe_get(style_width, "auto")
 
             if is_auto:
-                width = _safe_get(style_width, "min", 1)
+                width = _safe_get(style_width, "min", 0)
                 wmax = _safe_get(style_width, "max")
 
                 self._autowidth_columns[column] = {"max": wmax}
@@ -234,7 +242,7 @@ class Tabular(object):
         return dict(zip(self._columns, row))
 
     def _attrs_to_dict(self, row):
-        return {c: getattr(row, c) for c in self._columns}
+        return {c: getattr(row, c, self._nothings[c]) for c in self._columns}
 
     def _choose_normalizer(self, row):
         if isinstance(row, Mapping):
@@ -416,7 +424,7 @@ class Tabular(object):
             if isinstance(value, tuple):
                 initial, fn = value
             else:
-                initial = ""
+                initial = NOTHING
                 # Value could be a normal (non-callable) value or a
                 # callable with no initial value.
                 fn = value
@@ -526,6 +534,16 @@ class Tabular(object):
         row = self._normalizer(row)
         callables = self._strip_callables(row)
 
+        # Fill in any missing values.  Note: If the un-normalized data is an
+        # object, we already handle this in its normalizer, _attrs_to_dict.
+        # When the data is given as a dict, we do it here instead of its
+        # normalizer because there may be multi-column tuple keys.
+        if self._normalizer == self._identity:
+            for column in self._columns:
+                if column in row:
+                    continue
+                row[column] = self._nothings[column]
+
         with self._write_lock():
             if not self._rows:
                 self._maybe_write_header()


Test patch:
diff --git a/pyout/tests/test_field.py b/pyout/tests/test_field.py
index 2a36ff1..0c1f9fa 100644
--- a/pyout/tests/test_field.py
+++ b/pyout/tests/test_field.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 import pytest
-from pyout.field import Field, StyleProcessors
+from pyout.field import Field, Nothing, StyleProcessors
 
 
 def test_field_base():
@@ -37,6 +37,17 @@ def test_field_processors():
         field.add("pre", "not registered key")
 
 
+@pytest.mark.parametrize("text", ["", "-"], ids=["text=''", "text='-'"])
+def test_something_about_nothing(text):
+    nada = Nothing(text=text)
+    assert not nada
+
+    assert str(nada) == text
+    assert "{:5}".format(nada) == "{:5}".format(text)
+    assert "x" + nada  == "x" + text
+    assert nada + "x"  == text + "x"
+
+
 def test_truncate_mark_true():
     fn = StyleProcessors.truncate(7, marker=True)
 
diff --git a/pyout/tests/test_tabular.py b/pyout/tests/test_tabular.py
index d695b83..1964f88 100644
--- a/pyout/tests/test_tabular.py
+++ b/pyout/tests/test_tabular.py
@@ -51,6 +51,48 @@ def test_tabular_write_color():
     assert eq_repr(fd.getvalue(), expected)
 
 
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_empty_string():
+    fd = StringIO()
+    out = Tabular(stream=fd)
+    out({"name": ""})
+    assert eq_repr(fd.getvalue(), "\n")
+
+
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_missing_column():
+    fd = StringIO()
+    out = Tabular(columns=["name", "status"], stream=fd)
+    out({"name": "solo"})
+    assert eq_repr(fd.getvalue(), "solo \n")
+
+
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_missing_column_missing_text():
+    fd = StringIO()
+    out = Tabular(columns=["name", "status"],
+                  style={"status":
+                         {"missing": "-"}},
+                  stream=fd)
+    out({"name": "solo"})
+    assert eq_repr(fd.getvalue(), "solo -\n")
+
+
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_missing_column_missing_object_data():
+    class Data(object):
+        name = "solo"
+    data = Data()
+
+    fd = StringIO()
+    out = Tabular(columns=["name", "status"],
+                  style={"status":
+                         {"missing": "-"}},
+                  stream=fd)
+    out(data)
+    assert eq_repr(fd.getvalue(), "solo -\n")
+
+
 @patch("pyout.tabular.Terminal", TestTerminal)
 def test_tabular_write_columns_from_orderdict_row():
     fd = StringIO()
@@ -601,6 +643,27 @@ def test_tabular_write_intervals_bold():
     assert eq_repr(fd.getvalue(), expected)
 
 
+
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_intervals_missing():
+    fd = StringIO()
+    out = Tabular(style={"name": {"width": 3},
+                         "percent": {"bold": {"interval":
+                                              [[30, 50, False],
+                                               [50, 80, True]]},
+                                     "width": 2}},
+                  stream=fd, force_styling=True)
+    out(OrderedDict([("name", "foo"),
+                     ("percent", 78)]))
+    # Interval lookup function can handle a missing value.
+    out(OrderedDict([("name", "bar")]))
+
+    expected = "foo " + unicode_cap("bold") + \
+               "78" + unicode_cap("sgr0") + "\n" + \
+               "bar   \n"
+    assert eq_repr(fd.getvalue(), expected)
+
+
 @patch("pyout.tabular.Terminal", TestTerminal)
 def test_tabular_write_transform():
     fd = StringIO()
@@ -888,6 +951,26 @@ def test_tabular_write_callable_values():
     assert len([ln for ln in lines if ln.endswith("baz over    ")]) == 1
 
 
+@pytest.mark.timeout(10)
+@patch("pyout.tabular.Terminal", TestTerminal)
+def test_tabular_write_callable_transform_nothing():
+    delay0 = Delayed(3)
+
+    fd = StringIO()
+    out = Tabular(["name", "status"],
+                  style={"status": {"transform": lambda n: n + 2}},
+                  stream=fd)
+    with out:
+        # The unspecified initial value is set to Nothing().  The
+        # transform function above, which is designed to take a
+        # number, won't be called with it.
+        out({"name": "foo", "status": delay0.run})
+        assert eq_repr(fd.getvalue(), "foo \n")
+        delay0.now = True
+    lines = fd.getvalue().splitlines()
+    assert len([ln for ln in lines if ln.endswith("foo 5")]) == 1
+
+
 @pytest.mark.timeout(10)
 @patch("pyout.tabular.Terminal", TestTerminal)
 def test_tabular_write_callable_values_multi_return():