Jonathan Eunice avatar Jonathan Eunice committed dad0bfc

Text class now ready for use

Comments (0)

Files changed (7)

  *  Indentation and wrapping (to help stucture output)
  *  Convenience printing functions for horizontal rules (lines), titles, and
     vertical whitespace.
+ *  A convenient text aggregator class, Text, for easily building mutli-line
+    text objects a little bit at a time.
 
 Usage
 =====
 Good options for the separator might be be '-', '=', or parts of the `Unicode 
 box drawing character set <http://en.wikipedia.org/wiki/Box-drawing_character>`_.
 
+Text and Templates
+==================
+
+Often the job of output is not about individual text lines, but about creating
+multi-line files such as scripts and reports. ``say`` has you covered there as
+well.::
+
+    from say import Text
+    
+    # assume `hostname` and `filepath` already defined
+    
+    script = Text()
+    script += """
+        !#/bin/bash
+        
+        # Output the results of a ping command to the given file
+        
+        ping {hostname} >{filepath}
+    """
+    
+    script.write_to("script.sh")
+    
+``Text`` objects are basically a list of text lines. In most cases, when you add
+text (either as multi-line strings or lists of strings), ``Text`` will
+automatically interopolate variables the same way ``say`` does. One can
+simply ``print`` or
+``say`` ``Text`` objects, as their ``str()`` value is the full text you would
+assume. ``Text`` objects have both ``text`` and ``lines`` properties which
+can be either accessed or assigned to.
+    
+In the example above, the``+=`` assignment isn't strictly necessary; the
+``Text(...)`` constructor will itself accept a string or set of lines. If you
+want to preserve leading white space (no dedent), you can use the ``|=``
+operator instead. Or, if you don't want the string iterpreted at all, use
+``&=``. There is a ``read_from`()` method as well as the demonstrated
+``write_to()``.
+
+``Text`` objects are accessible via ``[]`` indexing, and they are iterable.
+Their ``len()`` is the number of lines they contain. As with lists, one can
+``append()`` or ``extend()`` them with one or multiple strings, respectively.
+``append()`` takes a keyword parameter ``interpolate`` that controls whether
+``{}`` expressions in the string are interpolated. ``extend()`` also takes an
+``interpolate`` flag, as well as a ``dedent`` flag that, if true, will
+automatically remove blank starting and ending lines, and any whitespace prefix
+that is common to all of the lines.
+
+``Text`` doesn't have the full set of text-onboarding options seen in `textdata
+<http://pypi.python.org/pypi/textdata>`_, but it should suit many cirumstances.
+If you need more, ``textdata`` can be used alongside ``Text``.
+
 Python 3
 ========
 
 from say.core import *
+from say.text import Text
         cloned.options = self.options.push(kwargs)
         return cloned
 
-    def __call__(self, *args, **kwargs): 
+    def __call__(self, *args, **kwargs):
+        """
+        Primary interface. say(something)
+        """
         
         opts = self.options.push(kwargs)
         if not opts._callframe:
         formatted = [ _sprintf(arg, opts._callframe) if is_string(arg) else str(arg)
                       for arg in args ]
         return self._output(opts.sep.join(formatted), opts)
+
+    def __gt__(self, arg):
+        """
+        Simple, non-functional call.  EXPERIMENTAL. Based on Python order of
+        operator precedence, probably not all that valuable.
+        """
         
+        opts = self.options.push({})
+        opts._callframe = inspect.currentframe().f_back
+        
+        formatted = [ _sprintf(arg, opts._callframe) if is_string(arg) else str(arg) ]
+        return self._output(opts.sep.join(formatted), opts)
+
+  
     def _output(self, data, opts):
         """
         Do the actual formatting and outputting work. ``data`` may be either a
+"""
+A text object that uses say to aggregate lines, say in a file or script.
+"""
+
+from say.core import fmt
+import inspect
+import sys, os
+
+_PY3 = sys.version_info[0] > 2
+
+class Text(object):
+    def __init__(self, data=None, interpolate=True, dedent=True):
+        self._lines = []
+        if data:
+            lines = self._data_to_lines(data)
+            callframe = inspect.currentframe().f_back
+            self.extend(lines, callframe, interpolate, dedent)
+        
+    def _dedent_lines(self, lines):
+        """
+        Given a list of lines, remove a leading or trailing blank line (if any),
+        as well as common leading blank prefixes.
+        """
+        if lines and lines[0].strip() == "":
+            lines.pop(0)
+        if lines and lines[-1].strip() == "":
+            lines.pop()
+        if lines:
+            nonblanklines = [ line for line in lines if line.strip() != "" ]
+            # piggyback os.path function to compute longest common prefix
+            prefix = os.path.commonprefix(nonblanklines)
+            # but then determine how much of that prefix is blank
+            prelen, maxprelen = 0, len(prefix)
+            while prelen < maxprelen and prefix[prelen] == ' ':
+                prelen += 1
+            if prelen:
+                lines = [ line[prelen:] for line in lines ]
+        return lines
+
+    def __iadd__(self, data):
+        """
+        In-place add the text or lines contained in data, with auto-dedent.
+        """
+        lines = self._data_to_lines(data)
+        callframe = inspect.currentframe().f_back
+        self.extend(lines, callframe, interpolate=True, dedent=True)
+        return self
+    
+    def __ior__(self, data):
+        """
+        In-place add the text or lines contained in data, with NO auto-dedent.
+        """
+        lines = self._data_to_lines(data)
+        callframe = inspect.currentframe().f_back
+        self.extend(lines, callframe, interpolate=True, dedent=False)
+        return self
+
+    def __iand__(self, data):
+        """
+        In-place add the text or lines contained in data, with NO auto-dedent
+        and NO iterpolation.
+        """
+        lines = self._data_to_lines(data)
+        self._lines.extend(lines)
+        return self
+
+    def append(self, line, callframe=None, interpolate=True):
+        if interpolate:
+            callframe = callframe or inspect.currentframe().f_back
+            line = fmt(line, _callframe=callframe)
+        self._lines.append(line)
+    
+    def extend(self, lines, callframe=None, interpolate=True, dedent=True):
+        if dedent:
+            lines = self._dedent_lines(lines)
+        if interpolate:
+            callframe = callframe or inspect.currentframe().f_back
+        for line in lines:
+            self.append(line, callframe, interpolate)
+            
+    def __getitem__(self, n):
+        return self._lines[n]
+
+    def __setitem__(self, n, value):
+        self._lines[n] = value.rstrip('\n')
+    
+    def __len__(self):
+        return len(self._lines)
+    
+    def __iter__(self):
+        return iter(self._lines)
+    
+    def _data_to_lines(self, data):
+        if isinstance(data, list):
+            return [ line.rstrip('\n') for line in data ]
+        else:
+            return data.splitlines()
+    
+    @property
+    def text(self):
+        return '\n'.join(self._lines)
+
+    @text.setter
+    def text(self, data):
+        self._lines = self._data_to_lines(data)
+            
+    @property
+    def lines(self):
+        return self._lines[:]
+    
+    @lines.setter
+    def lines(self, somelines):
+        self._lines = [ line.rstrip('\n') for line in somelines ]
+        
+    def __str__(self):
+        return self.text
+    
+    def __repr__(self):
+        return 'Text({0}, {1} lines)'.format(id(self), len(self._lines))
+    
+    def copy(self):
+        """
+        Make a copy.
+        """
+        newt = Text()
+        newt._lines = self._lines[:]
+        return newt
+    
+    def read_from(self, filepath, interpolate=True):
+        """
+        Reads lines from the designated file, adding them to the given Text.
+        If called as a class method, creates a new text. By default, interpolates
+        any {} expressions just as say and str.format do, but that can be turned off.
+        """
+        t = Text() if self == Text else self  # enable Text.read_from() constructor
+        lines = open(filepath).read().splitlines()
+        t.extend(lines, inspect.currentframe().f_back, interpolate=interpolate)
+        return t            
+    
+    def write_to(self, filepath, append=False, encoding='utf-8'):
+        mode = "a" if append else "w"
+        with open(filepath, "w") as f:
+            f.write(self.text.encode(encoding))
+            
+    # TODO: add a backup feature
+    
+# TODO: Consider additional `list` and `str` methods
+# TODO: Possibly add in-place replacement, modification, etc.
+# TODO: Extend tests
 
 setup(
     name='say',
-    version=verno("0.838"),
+    version=verno("0.869"),
     author='Jonathan Eunice',
     author_email='jonathan.eunice@gmail.com',
     description='Super-simple templated printing. E.g.: say("Hello, {whoever}!", indent=1)',
     status = "OK"
     assert say("This is {status}", silent=True) == "This is OK"
     
+def test_gt():
+    setup()
+    
+    x = 555
+    assert (say > "{x} is a big number!") =="555 is a big number!"
+    
 def test_hr_and_title():
     setup()
     

test/test_text.py

+
+import six, os
+from say import Text
+
+def test_text_basic():
+   t = Text()
+   
+   x, y, s = 1, 2, 'this thing'
+   t.append('{x} < {y}')
+   assert t.text == '1 < 2'
+   t.append('{s} x')
+   t.append('and {s!r}')
+   assert t.text == "1 < 2\nthis thing x\nand 'this thing'"
+   
+def test_append_raw():
+    t = Text()
+    x = 212
+    t.append('{x}')
+    t.append('{x}', interpolate=False)
+    assert t.text == '212\n{x}'
+    
+def test_len():
+    
+    t = Text('1\n2\n\3\n')
+    assert len(t) == 3
+    
+def test_ior():
+    t = Text('one')
+    
+    x = 2
+    t |= '   two {x}'
+    assert t.text == 'one\n   two 2'
+
+def test_iand():
+    t = Text('one')
+    
+    x = 2
+    t &= '   two {x}'
+    assert t.text == 'one\n   two {x}'
+
+
+def test_iadd():
+    t = Text()
+    
+    t += """
+        # this is a script
+        # which should be left aligned and compact
+            # with one indented line
+    """
+    assert t.text == "# this is a script\n# which should be left aligned and compact\n    # with one indented line"
+
+    t2 = Text()
+    t2 += """
+        # this is a script
+        # which should be left aligned and compact
+            # with one indented line
+    """
+    assert t2.text == "# this is a script\n# which should be left aligned and compact\n    # with one indented line"
+ 
+def test_indexing():
+    t = Text('this\nis\nsomething')
+    assert t[0] == 'this'
+    assert t[1] == 'is'
+    assert t[2] == 'something'
+    t[1] = 'would be'
+    assert t.text == 'this\nwould be\nsomething'
+    
+def test_set_text():
+    t = Text()
+    
+    t.text = 'this\nis'
+    assert t.text == 'this\nis'
+    assert t.lines == ['this', 'is']
+    
+    t.lines = ['and', 'one', 'more']
+    assert t.text == 'and\none\nmore'
+    
+def test_excess_newlines():
+    t = Text()
+    
+    t.lines = [ 'too\n', 'many\n', 'newlines\n' ]
+    assert t.text == 'too\nmany\nnewlines'
+    assert t.lines == [ 'too', 'many', 'newlines' ]
+    
+def test_iter():
+    t = Text('someday\nsoon')
+    assert [ x for x in t ] == ['someday', 'soon' ]
+    
+def test_init():
+        
+    t = Text('someday\nsoon')
+    assert t.text == 'someday\nsoon'
+    
+    t = Text(['someday', 'soon' ])
+    assert t.text == 'someday\nsoon'
+    
+    b = 'boy'
+    
+    t2 = Text('hey {b}')
+    assert t2.text == 'hey boy'
+    
+def test_str():
+    t = Text('someday\nsoon')
+
+    assert str(t) == 'someday\nsoon'
+    
+def test_repr():
+    t = Text('someday\nsoon')
+
+    import re
+    assert re.match('Text\(\d+, \d+ lines\)', repr(t))
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.