say / say /

Full commit
"""Interpolating string formatter. """

import string, inspect, sys, itertools, os, re
from stuf import chainstuf  
from options import Options, OptionsContext
import six
if six.PY3:
    from codecs import getencoder
from textwrap import fill, dedent

### Convenience and compatibility functions and names

stdout = sys.stdout
stderr = sys.stderr

def is_string(v):
    Is the value v a string? Useful especially in making a test that works on
    both Python 2.x and Python 3.x
    return isinstance(v, six.string_types)

def encoded(u, encoding):
    Encode string u (denoting it is expected to be in Unicode) if there's
    encoding to be done. Tries to mask the difference between Python 2 and 3,
    which have highly different models of string processing, and different
    codec APIs and quirks. Some Python 3 encoders further require ``bytes``
    in, not ``str``. These are first encoded into utf-8, encoded, then
    if not encoding:
        return u
    elif six.PY3:
            return getencoder(encoding)(u)[0]
        except LookupError:
            name = encoding.replace('-','')  # base-64 becomes base64 e.g.
            bytesout = getencoder(name + '_codec')(u.encode('utf-8'))[0]
            if name in set(['base64', 'hex', 'quopri', 'uu']):
                return bytesout.decode('utf-8')
                return bytesout
        # NB PY3 requires lower-level interface for many codecs. s.encode('utf-8')
        # works fine, but others do not. Some codecs convert bytes to bytes,
        # and are not properly looked up by nickname (e.g. 'base64'). These are
        # managed by first encoding into utf-8, then if it makes sense decoding
        # back into a string. The others are things like bz2 and zlib--binary
        # encodings that have little use to us here.
        # There are also be some slight variations in results that will make
        # testing more fun. Possibly related to adding or not adding a terminal
        # newline.
        return u.encode(encoding)
def flatten(*args):
    Like itertools.chain(), but will pretend that single scalar values are singleton lists.
    Convenient for iterating over values whether they're lists or singletons.
    flattened = [ x if isinstance(x, (list, tuple)) else [x] for x in args ]
    return itertools.chain(*flattened)

    # would use ``hasattr(x, '__iter__')`` rather than ``isinstance(x, (list, tuple))``,
    # but other objects like file have ``__iter__``, which screws things up

### Workhorse functions

sformatter = string.Formatter()  # piggyback Python's format() template parser

def _sprintf(arg, caller, override=None):
    Format the template string (arg) with the values seen in the context of the caller.
    If override is defined, it is a dictionary like object mapping additional
    values atop those of the local context.

    def seval(s):
        Evaluate the string s in the caller's context. Return its value.
            localvars = caller.f_locals if override is None \
                                        else chainstuf(override, caller.f_locals)
            return eval(s, caller.f_globals, localvars)
        except SyntaxError:
            raise SyntaxError("syntax error when formatting '{}'".format(s))

    if is_string(arg):
        parts = []
        for (literal_text, field_name, format_spec, conversion) in sformatter.parse(arg):
            if field_name is not None:
                format_str = six.u("{0") + ("!" + conversion if conversion else "") + \
                                          (":" + format_spec if format_spec else "") + "}"
                field_value = seval(field_name)
                formatted = format_str.format(field_value)
        return ''.join(parts)
        return str(seval(str(arg)))

### Core Say class

class Say(object):
    Say encapsulates printing functions. Instances are configurable, and callable.
    options = Options(  
        indent=0,           # indent level (if set to None, indentation is turned off)
        indent_str='    ',  # indent string for each level
        encoding=None if six.PY3 else 'utf-8',
                            # character encoding for output (needed on Python 2, not 3)
        encoded=None,       # character encoding to return
        files= [stdout],    # where is output headed? a list of write() able objects
                            # NB Set to [stdout] but in way that gets around stuf bug
        wrap=None,          # column to wrap text to, if any
        sep=' ',            # separate args with this (Python print function compatible)
        end='\n',           # end output with this (Python print function compatible)
        silent=False,       # do the formatting and return the result, but don't write the output
        retvalue=False,     # should return the value of the formatted string?
        _callframe=None,    # frome from which the caller was calling
        indent = lambda v, cur: cur.indent + int(v) if isinstance(v, str) else v
    def __init__(self, **kwargs):
        Make a say object with the given options.
        self.options = Say.options.push(kwargs)
    def hr(self, sep=six.u('\u2500'), width=40, vsep=0, **kwargs):
        Print a horizontal line. Like the HTML hr tag. Optionally
        specify the width, character repeated to make the line, and vertical separation.
        Good options for the separator may be '-', '=', or parts of the Unicode 
        box drawing character set.
        opts = self.options.push(kwargs)

        self.blank_lines(vsep, **opts)
        self._output([sep * width], opts)
        self.blank_lines(vsep, **opts)

    def title(self, name, sep=six.u('\u2500'), width=15, vsep=0, **kwargs):
        Print a horizontal line with an embedded title. 
        opts = self.options.push(kwargs)

        self.blank_lines(vsep, **opts)
        line = sep * width
        self._output([ ' '.join([line, name, line]) ], opts)
        self.blank_lines(vsep, **opts)
    def blank_lines(self, n, **kwargs):
        Output N blank lines ("vertical separation")
        if n > 0:
            opts = self.options.push(kwargs)
            self._write("\n" * n, opts)

    def set(self, **kwargs):
        Permanently change the reciver's settings to those defined in the kwargs.
        An update-like function.
    def setfiles(self, files):
        Set the list of output files. ``files`` is a list. For each item, if
        it's a real file like ``sys.stdout``, use it. If it's a string, assume
        it's a filename and open it for writing. 
        def opened(f):
            If f is a string, consider it a file name and return it, ready for writing.
            Else, assume it's an open file. Just return it.
            return open(f, "w") if is_string(f) else f
        self.options.files = [ opened(f) for f in files ]
        return self
        # TBD: Turn this into 'magical' attribute set
    def settings(self, **kwargs):
        Open a context manager for a `with` statement. Temporarily change settings
        for the duration of the with.
        return SayContext(self, kwargs)
    def clone(self, **kwargs):
        Create a new Say instance whose options are chained to this instance's
        options (and thence to Say.options). kwargs become the cloned instance's
        overlay options.
        cloned = Say()
        cloned.options = self.options.push(kwargs)
        return cloned

    def __call__(self, *args, **kwargs): 
        opts = self.options.push(kwargs)
        if not opts._callframe:
            opts._callframe = inspect.currentframe().f_back
        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 _output(self, data, opts):
        Do the actual formatting and outputting work. ``data`` may be either a
        list of lines, or a composed string.
        if opts.indent or opts.wrap:
            indent_str = opts.indent_str * opts.indent
            if opts.wrap:
                datastr = '\n'.join(data) if isinstance(data, list) else data
                outstr = fill(datastr, width=opts.wrap, initial_indent=indent_str, subsequent_indent=indent_str)
                datalines = data if isinstance(data, list) else data.splitlines()
                outstr = '\n'.join([ ''.join([ indent_str, line ]) for line in datalines ])
            outstr = '\n'.join(data) if isinstance(data, list) else data
        # by end of indenting, dealing with string only

        # prepare and emit output
        if opts.end is not None:
            outstr += opts.end
        if not opts.silent:
            to_write = encoded(outstr, opts.encoding)
            self._write(to_write, opts)
        # prepare and return return value
        if opts.retvalue:
            retencoding = opts.encoding if opts.encoded is True else opts.encoded
            retencoded = encoded(outstr, retencoding)
            rettrimmed = retencoded[:-1] if retencoded.endswith('\n') else retencoded
            return rettrimmed

    def _write(self, s, opts):
        Write s to all associated file objects. 
        # print(opts)
        for f in opts.files:

class SayContext(OptionsContext):
    Context helper to support Python's with statement.  Generally called
    from ``with say.settings(...):``

### Define default ``say`` and ``fmt`` callables

say = Say()

fmt = say.clone(encoding=None, retvalue=True, silent=True)