Source

say / say.py

"""Interpolating string formatter. """

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

from linecache import getline
import os, re


### 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
    decoded.
    """
    if not encoding:
        return u
    elif six.PY3:
        try:
            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')
            else:
                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.
    else:
        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.
        """
        try:
            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):
            parts.append(literal_text)
            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)
                parts.append(formatted)
        return ''.join(parts)
    else:
        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
    )
    
    options.magic(
        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. http://en.wikipedia.org/wiki/Box-drawing_character
        """
        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.
        """
        self.options.set(**kwargs)
        
    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)
            else:
                datalines = data if isinstance(data, list) else data.splitlines()
                outstr = '\n'.join([ ''.join([ indent_str, line ]) for line in datalines ])
        else:
            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:
            f.write(s)

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

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

say = Say()

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



def wrapped_if(value, prefix="", suffix="", transform=None):
    """
    If a string has a value, then transform it (optinally) and add the prefix and
    suffix. Else, return empty string. Handy for formatting operations, where
    one often wants to add decoration iff the value exists.
    """
    
    if not value:
        return ""
    s = transform(str(value)) if transform else str(value)
    return (prefix or "") + s + (suffix or "")

QUOTE_CHARS = ('"', "'", '"""')

def is_kwarg(s):
    """
    Rough test as to whether s is a kwarg to show()
    """
    return not s.startswith(QUOTE_CHARS) and '=' in s

class DebugPrinter(object):
    """DebugPrinter objects print debug output in a 'name: value' format that
    is convenient for discovering what's going on as a program runs."""
    
    options = Options(
        show_module=False,  # Show the module name in the call location
        where=True,         # show the call location of each dprint() (None => not explicitly)
        sep="  ",           # separate items with two spaces, by default
    )

    def __init__(self, **kwargs):
        self.options = DebugPrinter.options.push(kwargs)
        self.say = Say(retval=True)
        self.opts = None  # per call options, set on each call to reflect transient state

    def caller_arg_names(self, srcline):
        """
        Get the variable names the display function was called with.
        """
        
        # Get the contents in show(CONTENTS)
        contents = re.sub('^[^(]*\(', '', srcline)
        contents = re.sub('\)[^)]*$', '', contents)
        # really need to fix this to match out balanced parens, at least
        
        # While this successfully parses 95% of what you might typically want,
        # it's insufficient for show(CONTENTS) where CONTENTS contains commas
        # that don't separate the arguments. show, while called like a
        # function, must be on its own line as well (as though it were a statement)

        # Get the names of any non-keyword args. May be an easier and/or more
        # systematic and reliable way to do this via inspect or bytecode inspection
        return [ a.strip() for a in contents.split(',') if a and not is_kwarg(a) ]
    
    def call_location(self, caller):
        """
        Create a call location string indicating where a show() was called.
        """
        
        module_name = ""
        if self.opts.show_module:
            filepath = caller.f_locals.get('__file__', caller.f_globals.get('__file__', 'UNKNOWN'))
            filename = os.path.basename(filepath)
            module_name = re.sub(r'.py', '', filename)
            
        lineno = caller.f_lineno
        co_name = caller.f_code.co_name
        if co_name == '<module>':
            co_name = '__main__'
        func_location = wrapped_if(module_name, ":") + wrapped_if(co_name, "", "()")
        return "{}:{}".format(func_location, lineno)
        
    
    def arg_format(self, name, value, caller):
        """Format a single argument. Strings returned as-is."""
        if name.startswith(QUOTE_CHARS):
            ret = fmt(value, **{'_callframe': caller})
            return ret
        else:
            return "{0}: {1!r}".format(name, value)

    def get_arg_tuples(self, caller, *args):
        """
        Get arg name, value tuples uses to format.
        """
        
        srcline = getline(caller.f_code.co_filename, caller.f_lineno)
        arg_names  = self.caller_arg_names(srcline)
        return zip(arg_names, list(args))
        
    def __call__(self, *args, **kwargs):
        """Main entry point for DebugPrinter."""

        opts = self.opts = self.options.push(kwargs)
        
        # Determine argument names and values
        caller = inspect.currentframe().f_back
        argtuples = self.get_arg_tuples(caller, *args)
        
        valstr = opts.sep.join([ self.arg_format(name, value, caller) for name, value in argtuples ])
        locval = [ self.call_location(caller) + ":  ", valstr ] if opts.where else [ valstr ]

        return self.say(*locval, **kwargs)

show = DebugPrinter()