Commits

Jonathan Eunice  committed 4f7c99d

refactored and repackaged; show() now works interactively to some degree

  • Participants
  • Parent commits c354679

Comments (0)

Files changed (7)

 invocation, the more valuable having it stated in-line becomes. Note that full
 expressions are are supported. They are evaluated in the context of the caller.
 
-``say()`` is the API for standard printing functions. A parallel API,
-``show()``, helps rapidly print the current state of variables. It 
-enables
-"debugging print statements" that don't require the craptastic repetition of
-``print "x = {x}".format(x)``. Instead, just::
+Debug Printing
+==============
+
+Sometimes programs print so that users can see things, and sometimes they print
+so that develpopers can. ``say()`` is the API for standard printing functions. A
+parallel API, ``show()``, helps rapidly print the current state of variables. It
+enables "debugging print statements" that don't require the craptastic
+repetition of ``print "x = {x}".format(x)``. Instead, just::
 
     show(x)
     show(nums)
     x: 12  nums: [0, 1, 2, 3]  len(nums): 4
 
 All of the standard keyword options for ``say()`` work for ``show()`` as well.
+If you'd like to see where the data is being produced, ``show.set(where=True)``
+will turn on location reporting.
+
+Sadly, because Python provides weaker introspection during
+interactive operation, (see e.g. `this <http://stackoverflow.com/questions/13204161/how-to-access-the-calling-source-line-from-interactive-shell>`_)
+``show()`` is somewhat limited in interactive use. It works at the main
+level and usually in imported modules, but does work within the body of functions defined
+interactively.
 
 Printing Where You Like
 =======================
     successfully packaged for, and tested against, all late-model verions of
     Python: 2.6, 2.7, 3.2, and 3.3, as well as PyPy 1.9 (based on 2.7.2).
  
+ *  Debug printing via ``show()`` is now operational.
+    Draft support for interactive Python and iPython. Works within imported modules,
+    and at the interactive prompt, but cannot be used
+    within interactively defined functions. Unknown whether that is a hard limit
+    of Python introspection, or something that can be worked around over time.
+
  *  ``say`` has greater ambitions than just simple template printing. It's part
-    of a larger rethinking of how output should be formatted. Stay tuned.
+    of a larger rethinking of how output should be formatted. ``show()``
+    is an initial down-payment. Stay tuned for more.
  
  *  In addition to being a practical module in its own right, ``say`` is
     testbed for `options <http://pypi.python.org/pypi/options>`_, a package
  *  The author, `Jonathan Eunice <mailto:jonathan.eunice@gmail.com>`_ or
     `@jeunice on Twitter <http://twitter.com/jeunice>`_
     welcomes your comments and suggestions.
+    
+To-Dos
+======
+
+ *  Provide code that allows ``pylint`` to see that variables used inside
+    the ``say`` and ``fmt`` format strings are indeed thereby used.
 
 Installation
 ============

File __init__.py

-
-from say import *

File say/__init__.py

+from say.core import *
+from say.show import *
+from say.linecacher import *

File say/linecacher.py

+"""Like linecache, but with support for getting lines from
+interactive Python and iPython use, too."""
+
+import sys, linecache
+
+isInteractive = hasattr(sys, 'ps1') or hasattr(sys, 'ipcompleter')
+# http://stackoverflow.com/questions/967369/python-find-out-if-running-in-shell-or-not-e-g-sun-grid-engine-queue
+
+if isInteractive:
+    import readline as rl
+    
+    class History(object):
+        """
+        Singleton proxy for readline
+        """
+        
+        def __init__(self):
+            self._lines  = [ None ] # first item is None to compensate: 0-based arrays
+                                    # but 1-based line numbers
+            self._lines.append(rl.get_history_item(rl.get_current_history_length()))
+            rl.clear_history()
+            self._lastseen = rl.get_current_history_length()  # have we seen it all?
+            
+        @property
+        def lines(self):
+            """
+            The magically self-updating lines property.
+            """
+            self._update()
+            return self._lines
+            
+        def _update(self):
+            """
+            If the lines have not been recently updated (readlines knows more lines than
+            we do), import those lines.
+            """
+            cur_hist_len = rl.get_current_history_length()
+            if cur_hist_len > self._lastseen:
+                for i in range(self._lastseen+1, cur_hist_len+1):
+                    self._lines.extend(rl.get_history_item(i).splitlines())
+                self._lastseen = cur_hist_len
+                
+            # Fancier splitlines() thing required because iPython stores history
+            # lines for multi-line strings with embedded newlines. Iteractive Python
+            # stores them individually.
+        
+        def prev(self, offset=0):
+            """
+            Show the previous line. Or can go back a few, with offset
+            """
+            return self.lines[-2-abs(offset)]
+        
+        def clear(self):
+            """
+            Obliviate! Clear the history.
+            """
+            rl.clear_history()
+            self._lines  = [ None ] # first item is None to compensate: 0-based arrays
+                                    # but 1-based line numbers
+            self._lines.append(rl.get_history_item(rl.get_current_history_length()))
+        
+        def show(self):
+            """
+            Show last items.
+            """
+            for lineno, line in enumerate(self.lines[1:], start=1):
+                print "{0:3}: {1}".format(lineno, line)
+                
+    history = History()
+    
+    def frame_to_source_info(frame):
+        """
+        Given a Python call frame, e.g. from ``inspect.currentframe()`` or any of
+        its ``f_back`` predecessors, return the best filename and lineno combination.
+        """
+        filename = frame.f_code.co_filename
+        lineno   = frame.f_lineno
+        # print "lineno", lineno
+        if filename.startswith( ('<stdin>', '<ipython-input') ):
+            if lineno == 1:
+                lineno = len(history.lines)
+            return ('<stdin>', lineno)
+        return (filename, lineno)
+
+    def getline(filename, lineno):
+        """
+        Replace ``linecache.getline()`` with function that first determines if
+        we need to get from history, or from a regular file.
+        """
+        # print "getline: filename = ", filename, "lineno = ", lineno
+        if filename == '<stdin>':
+            index = -1 if lineno == 1 else lineno - 1
+            # for interactive Python, lineno == 1 a lot
+            return history.lines[index]
+        else:
+            return linecache.getline(filename, lineno)
+        
+else:
+    history = None
+    getline = linecache.getline
+    
+    def frame_to_source_info(frame):
+        """
+        Given a Python call frame, e.g. from ``inspect.currentframe()`` or any of
+        its ``f_back`` predecessors, return the best filename and lineno combination.
+        If not running interactively, just use the Python data structures.
+        """
+        return (frame.f_code.co_filename, frame.f_lineno)
+"""Debugging print features. """
+
+import string, inspect, sys, os, re, collections
+from options import Options, OptionsContext
+import linecache
+from say.core import *
+from say.linecacher import *
+import ast
+import codegen
+from mementos import MementoMetaclass
+import textwrap
+
+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 with_metaclass(meta, base=object):
+    """Create a base class with a metaclass."""
+    return meta("NewBase", (base,), {})
+
+class CallArgs(with_metaclass(MementoMetaclass, ast.NodeVisitor)):
+    """
+    An ``ast.NodeVisitor`` that parses a Python function call and determines its
+    arguments from the corresponding AST. Memoized so that parsing and
+    traversing the AST is done once and only once; subseqnet requests are
+    delivered via cache lookup.
+    """
+
+    TARGET_FUNCS = set(['show', 'show.items'])  # functions we care about
+
+    def __init__(self, filepath, lineno):
+        ast.NodeVisitor.__init__(self)
+        self.filepath = filepath
+        self.lineno   = lineno
+        self.source   = None  # placeholder
+        self.ast      = None  # placeholder
+        self.args     = None  # placeholder
+        self.get_ast()
+        self.visit(self.ast)
+    
+    def get_ast(self):
+        """
+        Find the AST. Easy if single source line contains whole line. May
+        need a bit of trial-and-error if over multiple lines.
+        """
+        src = ""
+        for lastlineno in range(self.lineno, self.lineno+10):
+            src += getline(self.filepath, lastlineno)
+            try:
+                srcleft = textwrap.dedent(src)
+                self.ast = ast.parse(srcleft)
+                self.source = src
+                return
+            except IndentationError:
+                pass
+            except SyntaxError:
+                pass
+        raise RuntimeError('Failed to parse:\n{}\n'.format(src))
+        
+    def visit_Call(self, node):
+        """
+        Called for all ``ast.Call`` nodes. Collects source of each argument.
+        Note that AST ``starargs`` and ``keywords`` properties are not
+        considered. Because ``CallArgs`` parses just one line of source code out
+        of its module's context, ``ast.parse`` assumes that arguments are
+        normal, not ``*args``. And ``**kwargs`` we can ignore, because those are
+        pragmas, not data.
+        """
+        
+        def call_name(n):
+            """
+            Given an ast.Call node, return the name of the called function, if
+            discoverable. Only attempts to decode the common ``func()`` and
+            ``object.method()`` cases that we care about for the ``show``
+            module. Others return ``None``.
+            """
+            if isinstance(n.func, ast.Name):
+                return n.func.id
+            elif isinstance(n.func, ast.Attribute):
+                a = n.func
+                if isinstance(a.value, ast.Name):
+                    return '.'.join([a.value.id, a.attr])
+                else:
+                    return None # could be an attribute of a call, but for those, we don't much care
+            else:
+                raise ValueError("Uh...I'm confused!")
+        
+        name = call_name(node)
+        if name in self.TARGET_FUNCS:
+            self.args = [ codegen.to_source(arg) for arg in node.args ]
+        else:
+            # visit its children
+            ast.NodeVisitor.generic_visit(self, node)
+
+# probably cannot make this work from interactive Python
+# http://stackoverflow.com/questions/13204161/how-to-access-the-calling-source-line-from-interactive-shell
+
+class Show(object):
+    """Show 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=False,        # show the call location of each call
+        sep="  ",           # separate items with two spaces, by default
+        retvalue=False,     # return the value printed?
+    )
+
+    def __init__(self, **kwargs):
+        self.options = Show.options.push(kwargs)
+        self.say = Say(retvalue=self.options.retvalue)
+        self.opts = None  # per call options, set on each call to reflect transient state
+    
+    def call_location(self, caller):
+        """
+        Create a call location string indicating where a show() was called.
+        """
+        if isInteractive:
+            return "<stdin>:{0}".format(len(history.lines))
+        else:
+            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 ':'.join([func_location, str(lineno)])
+    
+    def arg_format(self, name, value, caller):
+        """Format a single argument. Strings returned as-is."""
+        if name.startswith(QUOTE_CHARS):
+            return fmt(value, **{'_callframe': caller})
+        else:
+            formatted = "{0}: {1!r}".format(name, value)
+            if isinstance(value, dict):
+                # escape { and }
+                formatted = formatted.replace('{', '{{').replace('}', '}}') # escape for dict case
+            return formatted
+
+    def arg_format_items(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:                
+            if isinstance(value, (list, dict, set, six.string_types)):  # weak test
+                length = len(value)
+                itemname = 'char' if isinstance(value, six.string_types) else 'item'
+                s_or_nothing = '' if length == 1 else 's'
+                return "{0} ({1} {2}{3}): {4!r}".format(name, length, itemname, s_or_nothing, value)
+            else:
+                return "{0}: {1!r}".format(name, value)
+        
+    def get_arg_tuples(self, caller, values):
+        """
+        Return a list of argument name, value tuples with the given values.
+        """
+        filename, lineno = frame_to_source_info(caller)
+        argnames = CallArgs(filename, lineno).args
+        argtuples = list(zip(list(argnames), list(values)))
+        return argtuples
+    
+    def settings(self, **kwargs):
+        """
+        Open a context manager for a `with` statement. Temporarily change settings
+        for the duration of the with.
+        """
+        return ShowContext(self, kwargs)
+    
+    def set(self, **kwargs):
+        """
+        Open a context manager for a `with` statement. Temporarily change settings
+        for the duration of the with.
+        """
+        self.options.set(**kwargs)
+        if kwargs:
+            self.say.set(**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 = Show()
+        cloned.options = self.options.push(kwargs)
+        return cloned
+
+    def __call__(self, *args, **kwargs):
+        """Main entry point for Show objects."""
+
+        opts = self.opts = self.options.push(kwargs)
+        
+        # Determine argument names and values
+        caller = inspect.currentframe().f_back
+        argtuples = self.get_arg_tuples(caller, args)
+        
+        # Construct the result string
+        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 ]
+
+        # Emit the result string, and optionally return it
+        retval = self.say(*locval, **kwargs)
+        if opts.retvalue:
+            return retval
+        
+    def items(self, *args, **kwargs):
+        
+        opts = self.opts = self.options.push(kwargs)
+        
+        assert len(args) == 1
+        
+        # Determine argument names and values
+        caller = inspect.currentframe().f_back
+        argtuples = self.get_arg_tuples(caller, args)
+        # say("argtuples = {argtuples!r}")
+
+        valstr = opts.sep.join([ self.arg_format_items(name, value, caller) for name, value in argtuples ])
+        locval = [ self.call_location(caller) + ":  ", valstr ] if opts.where else [ valstr ]
+
+        retval = self.say(*locval, **kwargs)
+        if opts.retvalue:
+            return retval
+
+class ShowContext(OptionsContext):
+    """
+    Context helper to support Python's with statement.  Generally called
+    from ``with show.settings(...):``
+    """
+    pass
+
+show = Show()
 
 setup(
     name='say',
-    version=verno("0.620"),
+    version=verno("0.8"),
     author='Jonathan Eunice',
     author_email='jonathan.eunice@gmail.com',
     description='Super-simple templated printing. E.g.: say("Hello, {whoever}!", indent=1) or show(x)',
     long_description=open('README.rst').read(),
     url='https://bitbucket.org/jeunice/say',
     packages=['say'],
-    install_requires=['six','options>=0.325', 'stuf>=0.9.10'],
+    install_requires=['six', 'options>=0.325', 'stuf>=0.9.10', 'mementos>=0.465', 'codegen>=1.0'],
     tests_require = ['tox', 'pytest', 'six'],
     zip_safe = True,
     keywords='print format template interpolate say show',

File test/test_show.py

 
-from say import Show, Say
+import pytest
+from say import Show
 
 show = Show(where=False, retvalue=True)
 
+def nospaces(s):
+    return s.replace(' ', '')
+    
 def test_basic(param = 'Yo'):
 
     a = 1
     assert show(a) == 'a: 1'
     assert show(a, b) == 'a: 1  b: 3.141'
     assert show(a, b, c) == "a: 1  b: 3.141  c: 'something else!'"
-    assert show(1+1) == '1+1: 2'
+    
+    assert show(1 + 1) == '1 + 1: 2'
+    assert nospaces(show(1+1)) == nospaces('1 + 1: 2')
+        # may have extra spaces in it, based on codegen.to_source() output
+        
     assert show(len(c), c) == \
                 "len(c): 15  c: 'something else!'"
     
             # anything with paraens in it must be on separate line, given weak parser
             # of show parameters
             
+def test_literals():
+    assert show(1 + 1) == '1 + 1: 2'
+    assert show(1+1) == '1 + 1: 2'
+    
+    # assert nospaces(show(1+1)) == nospaces('1 + 1: 2')
+    
+    # NB output may have more or fewer spaces than actual parameter, based on codegen.to_source() 
+    # creating its 'idealized' code output
+
 def test_say_params():
     a = 1
     b = 3.141
     assert show(x, nums, len(nums), sep='\n') == \
         'x: 12\nnums: [0, 1, 2, 3]\nlen(nums): 4'
     assert show(x, nums, len(nums), sep='\n', indent=1) == \
-        '    x: 12\n    nums: [0, 1, 2, 3]\n    len(nums): 4'
+        '    x: 12\n    nums: [0, 1, 2, 3]\n    len(nums): 4'
+
+# @pytest.mark.skipif('True')  # under construction
+def test_show_items():
+    nums = list(range(4))
+    assert show.items(nums) == \
+        'nums (4 items): [0, 1, 2, 3]'
+    
+    astring = 'this'
+    assert show.items(astring) == \
+        "astring (4 chars): 'this'"
+    
+    #d = {'a': 1, 'b': 2}
+    #assert show.items(d) == \
+    #   "d (2 items): {'a': 1, 'b': 2}"