Repository: cool-RR/PySnooper

Issue:
Feature request: Add `exploding_variables` argument
Any exploding variable will not only be tracked, but all its attributes will be tracked too.

Discussion:
cool-RR: How is this better than `variables=d.keys()`?
alexmojaki: This shows values, not just keys, so I'm assuming you mean `d.items()`, which really is just as good as `d`. This ensures that all items and their changes are shown and nicely formatted. Otherwise you get the problem in #73, and even when modifications are reported, the user has to manually search through entries to find the differences.
cool-RR: Ah, I understand. 

I don't like all these classes that people will need to know. I'm sorry, I'm afraid of adding more complexity to the project for features that I don't know whether people will find useful. 
alexmojaki: Also `reprlib` doesn't have good reprs for mappings other than raw `dict`s, so for example in this case:

```python
from collections import OrderedDict

import pysnooper
from pysnooper.variables import Keys

@pysnooper.snoop(variables=(
        Keys('d'),
))
def foo():
    d = OrderedDict()
    d[1] = list(range(20))
    d[2] = list(range(40))

foo()
```

```
20:57:49.925572 call        11 def foo():
20:57:49.925890 line        12     d = OrderedDict()
New var:....... d = OrderedDict()
20:57:49.926047 line        13     d[1] = list(range(20))
New var:....... (d)[1] = [0, 1, 2, 3, 4, 5, ...]
Modified var:.. d = OrderedDict([(1, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19])])
20:57:49.926290 line        14     d[2] = list(range(40))
New var:....... (d)[2] = [0, 1, 2, 3, 4, 5, ...]
Modified var:.. d = OrderedDict([(1, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...8, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39])])
20:57:49.926611 return      14     d[2] = list(range(40))
Return value:.. None
```

you can really only see what happened in the `(d)[2] =` line, not the `Modified var:.. d = ` line.
alexmojaki: OK, I added automatic dispatching. It could alternatively use the Mapping and Sequence ABCs, each method has pros and cons. Then I'd suggest documenting the classes under an 'Advanced usage' section for people interested in greater control (making sure the right class is used, excluding keys, slicing indices, and maybe other functions in the future such as depth).
alexmojaki: I decided to go with the collections ABCs since that's less likely to trigger unwanted behaviour.

Here's a sample of the automatic API:

```python
from types import SimpleNamespace

import pysnooper


@pysnooper.snoop(exploded_variables='d point lst'.split())
def foo():
    d = {'a': 1, 'b': 2, 'c': 'ignore'}
    point = SimpleNamespace(x=3, y=4)
    lst = list(range(5))


foo()
```

```
09:11:50.682783 call         7 def foo():
09:11:50.683004 line         8     d = {'a': 1, 'b': 2, 'c': 'ignore'}
New var:....... (d)['a'] = 1
New var:....... (d)['b'] = 2
New var:....... (d)['c'] = 'ignore'
New var:....... d = {'a': 1, 'b': 2, 'c': 'ignore'}
09:11:50.683432 line         9     point = SimpleNamespace(x=3, y=4)
New var:....... (point).x = 3
New var:....... (point).y = 4
New var:....... point = namespace(x=3, y=4)
09:11:50.683996 line        10     lst = list(range(5))
New var:....... (lst)[0] = 0
New var:....... (lst)[1] = 1
New var:....... (lst)[2] = 2
New var:....... (lst)[3] = 3
New var:....... (lst)[4] = 4
New var:....... lst = [0, 1, 2, 3, 4]
09:11:50.684502 return      10     lst = list(range(5))
Return value:.. None
```
cool-RR: Could you rebase or resolve conflicts before I review?
cool-RR: Looks like there are still conflicts to resolve, do you need help with that?
alexmojaki: I'm not seeing any conflicts.
cool-RR: See this screenshot: https://www.dropbox.com/s/nd6pthuhr5ubol5/screenshot_HOPPER_2019-04-30_172302.jpg?dl=0

If you'll want help with the conflicts, let me know. 
cool-RR: See this screenshot: https://www.dropbox.com/s/nd6pthuhr5ubol5/screenshot_HOPPER_2019-04-30_172302.jpg?dl=0

If you'll want help with the conflicts, let me know. 
alexmojaki: ![screen](https://i.imgur.com/wJQhs1p.png)
alexmojaki: Send me the link to command line instructions and I'll take a look later.
cool-RR: @alexmojaki Can you please add me as a collaborator here? https://github.com/alexmojaki/PySnooper/
cool-RR: Sorry for the delay here. Looking good. I rebased and touched up a few places, the result is in the `development` branch. After you give me permission to your repo, I'll push it into this PR.

Here are more things needed to merge:

1. Tests
2. Update the docstring and the readme (or advanced usage, that's good too.)
4. Protect from infinite sequence, and test.
5. Use `ABC`, `@abstractmethod` and such on the classes in variables
6. `_safe_keys` should be a top-level function since it doesn't use `self`
7. No need for `()` in `raise NotImplementedError()`
8. I think you can use vars instead of __dict__ and __slots_. 

I'm willing to split a few items with you if you'd like. You should do at least the first two though, because problems with your design might come up. Let me know which of the items you'd like me to do.
alexmojaki: 3. I'm not iterating blindly anywhere any more as I removed `Enumerate`. `Indices` uses `len` so nothing infinite can happen.
4. The only candidate for `@abstractmethod` is `BaseVariable._items`. Adding it would probably waste reader's time as they think about why it hasn't been used on other methods.
5. `_safe_keys` uses `self`.
7. `vars` simply returns `__dict__`. There was a discussion to make it account for `__slots__` but it was rejected, which disappointed me.
cool-RR: I agree on 3 and 5.

Regarding 4: I think that `@abstractmethod` is fitting for `_items`.

Regarding 7: Okay, but there should be a test that this logic doesn't fail for a few objects that don't have `__dict__`.

cool-RR: Looking good. As far as I'm concerned, this only needs the "advanced usage" text file, with a link from the readme, and it's good to merge. Let me know if you'll want help on that. 
alexmojaki: I think we should probably go for a shorter parameter name, like `explode` or `expand`.
cool-RR: I do think that the argument name is too long, and `explode` would be cool, the problem is that it sounds too much like a boolean and I don't want people to try to pass `True` in. By the way, can you rename from `exploded_variables` to `exploding_variables`? I think the latter sounds cooler.
cool-RR: (If you'd like me to do the renaming, I will.) 
alexmojaki: I can attest that it's hard to remember if it's `exploded_variables` or `exploding_variables`. You wrote `exploding` in the issue, but I wrote `exploded` in the code, and then screwed up again and wrote `exploding` in the docs.
cool-RR: Actually, I'll just do it now.
alexmojaki: It also has nothing to do with variables specifically. It's for arbitrary expressions.
alexmojaki: On that note, the name `variables` doesn't make sense either, because all variables are tracked, it only makes sense to pass a more complicated expression.
cool-RR: > On that note, the name `variables` doesn't make sense either, because all variables are tracked, it only makes sense to pass a more complicated expression. I think `watch` would be better to fit with traditional debugger terminology. Maybe `expressions`.

Agreed.
alexmojaki: Awesome!

What do you think of just `exploding`? Shorter, doesn't sound like a boolean. Bit weird though.
cool-RR: Sorry, I've grown to really like `watch_explode` :)
alexmojaki: Oh, I didn't see that one. Commits are moving too fast. That's pretty good.

Patch:
diff --git a/README.md b/README.md
index f6666ab..586be94 100644
--- a/README.md
+++ b/README.md
@@ -75,12 +75,20 @@ If stderr is not easily accessible for you, you can redirect the output to a fil
 
 You can also pass a stream or a callable instead, and they'll be used.
 
-See values of some variables that aren't local variables:
+See values of some expressions that aren't local variables:
 
 ```python
-@pysnooper.snoop(variables=('foo.bar', 'self.whatever'))
+@pysnooper.snoop(watch=('foo.bar', 'self.x["whatever"]'))
 ```
 
+Expand values to see all their attributes or items of lists/dictionaries:
+
+```python
+@pysnooper.snoop(exploding_variables=('foo', 'self'))
+```
+
+(see [Advanced Usage](#advanced-usage) for more control)
+
 Show snoop lines for functions that your function calls:
 
 ```python
@@ -99,6 +107,24 @@ Start all snoop lines with a prefix, to grep for them easily:
 $ pip install pysnooper
 ```
 
+# Advanced Usage #
+
+`exploding_variables` will automatically guess how to expand the expression passed to it based on its class. You can be more specific by using one of the following classes:
+
+```python
+import pysnooper
+
+@pysnooper.snoop(variables=(
+    pysnooper.Attrs('x'),    # attributes
+    pysnooper.Keys('y'),     # mapping (e.g. dict) items
+    pysnooper.Indices('z'),  # sequence (e.g. list/tuple) items
+))
+```
+
+Exclude specific keys/attributes/indices with the `exclude` parameter, e.g. `Attrs('x', exclude=('_foo', '_bar'))`.
+
+Add a slice after `Indices` to only see the values within that slice, e.g. `Indices('z')[-3:]`.
+
 # Contribute #
 
 [Pull requests](https://github.com/cool-RR/PySnooper/pulls) are always welcome!
diff --git a/pysnooper/__init__.py b/pysnooper/__init__.py
index ff2aa4c..05156d3 100644
--- a/pysnooper/__init__.py
+++ b/pysnooper/__init__.py
@@ -2,12 +2,13 @@
 # This program is distributed under the MIT license.
 
 from .pysnooper import snoop
+from .variables import Attrs, Exploding, Indices, Keys
 import collections
 
 __VersionInfo = collections.namedtuple('VersionInfo',
                                        ('major', 'minor', 'micro'))
 
-__version__ = '0.0.26'
+__version__ = '0.0.27'
 __version_info__ = __VersionInfo(*(map(int, __version__.split('.'))))
 
 del collections, __VersionInfo # Avoid polluting the namespace
diff --git a/pysnooper/pysnooper.py b/pysnooper/pysnooper.py
index 849d923..cebfd51 100644
--- a/pysnooper/pysnooper.py
+++ b/pysnooper/pysnooper.py
@@ -39,7 +39,8 @@ def get_write_and_truncate_functions(output):
     return (write, truncate)
 
 
-def snoop(output=None, variables=(), depth=1, prefix='', overwrite=False):
+def snoop(output=None, watch=(), watch_explode=(), depth=1,
+          prefix='', overwrite=False):
     '''
     Snoop on the function, writing everything it's doing to stderr.
 
@@ -54,9 +55,15 @@ def snoop(output=None, variables=(), depth=1, prefix='', overwrite=False):
 
         @pysnooper.snoop('/my/log/file.log')
 
-    See values of some variables that aren't local variables::
+    See values of some expressions that aren't local variables::
 
-        @pysnooper.snoop(variables=('foo.bar', 'self.whatever'))
+        @pysnooper.snoop(watch=('foo.bar', 'self.x["whatever"]'))
+
+    Expand values to see all their attributes or items of lists/dictionaries:
+
+        @pysnooper.snoop(exploding_variables=('foo', 'self'))
+
+    (see Advanced Usage in the README for more control)
 
     Show snoop lines for functions that your function calls::
 
@@ -73,9 +80,11 @@ def snoop(output=None, variables=(), depth=1, prefix='', overwrite=False):
                         "content to file.")
     def decorate(function):
         target_code_object = function.__code__
-        tracer = Tracer(target_code_object=target_code_object, write=write,
-                        truncate=truncate, variables=variables, depth=depth,
-                        prefix=prefix, overwrite=overwrite)
+        tracer = Tracer(
+            target_code_object=target_code_object, write=write,
+            truncate=truncate, watch=watch, watch_explode=watch_explode,
+            depth=depth, prefix=prefix, overwrite=overwrite
+        )
 
         def inner(function_, *args, **kwargs):
             with tracer:
diff --git a/pysnooper/tracer.py b/pysnooper/tracer.py
index f029535..6c5e4c8 100644
--- a/pysnooper/tracer.py
+++ b/pysnooper/tracer.py
@@ -6,59 +6,20 @@ import re
 import collections
 import datetime as datetime_module
 import itertools
-try:
-    import reprlib
-    import builtins
-except ImportError:
-    import repr as reprlib
-    import __builtin__ as builtins
 
+from .variables import CommonVariable, Exploding, BaseVariable
 from .third_party import six
 from . import utils
 
-ipython_filename_pattern = re.compile('^<ipython-input-([0-9]+)-.*>$')
-
-
-class Repr(reprlib.Repr, object):  # reprlib.Repr is old-style in Python 2
-    def __init__(self):
-        super(Repr, self).__init__()
-        self.maxother = 100
-
-    def repr(self, x):
-        try:
-            return super(Repr, self).repr(x)
-        except Exception as e:
-            return '<{} instance at {:#x} (__repr__ raised {})>'.format(
-                x.__class__.__name__, id(x), e.__class__.__name__)
-
-    def repr_instance(self, x, level):
-        s = builtins.repr(x)
-        if len(s) > self.maxother:
-            i = max(0, (self.maxother - 3) // 2)
-            j = max(0, self.maxother - 3 - i)
-            s = s[:i] + '...' + s[len(s) - j:]
-        return s
 
-
-repr_instance = Repr()
-
-
-def get_shortish_repr(item):
-    r = repr_instance.repr(item)
-    r = r.replace('\r', '').replace('\n', '')
-    return r
+ipython_filename_pattern = re.compile('^<ipython-input-([0-9]+)-.*>$')
 
 
-def get_local_reprs(frame, variables=()):
-    result = {key: get_shortish_repr(value) for key, value
+def get_local_reprs(frame, watch=()):
+    result = {key: utils.get_shortish_repr(value) for key, value
                                                      in frame.f_locals.items()}
-    for variable in variables:
-        try:
-            result[variable] = get_shortish_repr(
-                eval(variable, (frame.f_globals or {}), frame.f_locals)
-            )
-        except Exception:
-            pass
+    for variable in watch:
+        result.update(variable.items(frame))
     return result
 
 
@@ -138,14 +99,18 @@ def get_source_from_frame(frame):
 
 
 class Tracer:
-    def __init__(self, target_code_object, write, truncate, variables=(),
-                 depth=1, prefix='', overwrite=False):
+    def __init__(self, target_code_object, write, truncate, watch=(),
+                 watch_explode=(), depth=1, prefix='', overwrite=False):
         self.target_code_object = target_code_object
         self._write = write
         self.truncate = truncate
-        if isinstance(variables, six.string_types):
-            variables = (variables,)
-        self.variables = variables
+        self.watch = [
+            v if isinstance(v, BaseVariable) else CommonVariable(v)
+            for v in utils.ensure_tuple(watch)
+         ] + [
+             v if isinstance(v, BaseVariable) else Exploding(v)
+             for v in utils.ensure_tuple(watch_explode)
+        ]
         self.frame_to_old_local_reprs = collections.defaultdict(lambda: {})
         self.frame_to_local_reprs = collections.defaultdict(lambda: {})
         self.depth = depth
@@ -203,7 +168,7 @@ class Tracer:
         self.frame_to_old_local_reprs[frame] = old_local_reprs = \
                                                self.frame_to_local_reprs[frame]
         self.frame_to_local_reprs[frame] = local_reprs = \
-                               get_local_reprs(frame, variables=self.variables)
+                                       get_local_reprs(frame, watch=self.watch)
 
         modified_local_reprs = {}
         newish_local_reprs = {}
@@ -255,7 +220,7 @@ class Tracer:
                    u'{line_no:4} {source_line}'.format(**locals()))
 
         if event == 'return':
-            return_value_repr = get_shortish_repr(arg)
+            return_value_repr = utils.get_shortish_repr(arg)
             self.write('{indent}Return value:.. {return_value_repr}'.
                                                             format(**locals()))
 
diff --git a/pysnooper/utils.py b/pysnooper/utils.py
index 2de6948..91e855f 100644
--- a/pysnooper/utils.py
+++ b/pysnooper/utils.py
@@ -2,9 +2,17 @@
 # This program is distributed under the MIT license.
 
 import abc
-import sys
 
 from .pycompat import ABC
+from .third_party import six
+
+try:
+    import reprlib
+    import builtins
+except ImportError:
+    import repr as reprlib
+    import __builtin__ as builtins
+
 
 def _check_methods(C, *methods):
     mro = C.__mro__
@@ -31,6 +39,7 @@ class WritableStream(ABC):
         return NotImplemented
 
 
+
 file_reading_errors = (
     IOError,
     OSError,
@@ -38,7 +47,47 @@ file_reading_errors = (
 )
 
 
+
 def shitcode(s):
     return ''.join(
         (c if (0 < ord(c) < 256) else '?') for c in s
     )
+
+
+class Repr(reprlib.Repr, object):  # reprlib.Repr is old-style in Python 2
+    def __init__(self):
+        super(Repr, self).__init__()
+        self.maxother = 100
+
+    def repr(self, x):
+        try:
+            return super(Repr, self).repr(x)
+        except Exception as e:
+            return '<{} instance at {:#x} (__repr__ raised {})>'.format(
+                x.__class__.__name__, id(x), e.__class__.__name__)
+
+    def repr_instance(self, x, level):
+        s = builtins.repr(x)
+        if len(s) > self.maxother:
+            i = max(0, (self.maxother - 3) // 2)
+            j = max(0, self.maxother - 3 - i)
+            s = s[:i] + '...' + s[len(s) - j:]
+        return s
+
+
+repr_instance = Repr()
+
+
+def get_shortish_repr(item):
+    r = repr_instance.repr(item)
+    r = r.replace('\r', '').replace('\n', '')
+    return r
+
+
+def ensure_tuple(x):
+    if isinstance(x, six.string_types):
+        x = (x,)
+    return tuple(x)
+
+
+
diff --git a/pysnooper/variables.py b/pysnooper/variables.py
new file mode 100644
index 0000000..6a97666
--- /dev/null
+++ b/pysnooper/variables.py
@@ -0,0 +1,108 @@
+import itertools
+import abc
+from collections import Mapping, Sequence
+from copy import deepcopy
+
+from . import utils
+from . import pycompat
+
+
+class BaseVariable(pycompat.ABC):
+    def __init__(self, source, exclude=()):
+        self.source = source
+        self.exclude = utils.ensure_tuple(exclude)
+        self.code = compile(source, '<variable>', 'eval')
+
+    def items(self, frame):
+        try:
+            main_value = eval(self.code, frame.f_globals or {}, frame.f_locals)
+        except Exception:
+            return ()
+        return self._items(main_value)
+
+    @abc.abstractmethod
+    def _items(self, key):
+        raise NotImplementedError
+
+
+class CommonVariable(BaseVariable):
+    def _items(self, main_value):
+        result = [(self.source, utils.get_shortish_repr(main_value))]
+        for key in self._safe_keys(main_value):
+            try:
+                if key in self.exclude:
+                    continue
+                value = self._get_value(main_value, key)
+            except Exception:
+                continue
+            result.append((
+                '({}){}'.format(self.source, self._format_key(key)),
+                utils.get_shortish_repr(value)
+            ))
+        return result
+
+    def _safe_keys(self, main_value):
+        try:
+            for key in self._keys(main_value):
+                yield key
+        except Exception:
+            pass
+
+    def _keys(self, main_value):
+        return ()
+
+    def _format_key(self, key):
+        raise NotImplementedError
+
+    def _get_value(self, main_value, key):
+        raise NotImplementedError
+
+
+class Attrs(CommonVariable):
+    def _keys(self, main_value):
+        return itertools.chain(
+            getattr(main_value, '__dict__', ()),
+            getattr(main_value, '__slots__', ())
+        )
+
+    def _format_key(self, key):
+        return '.' + key
+
+    def _get_value(self, main_value, key):
+        return getattr(main_value, key)
+
+
+class Keys(CommonVariable):
+    def _keys(self, main_value):
+        return main_value.keys()
+
+    def _format_key(self, key):
+        return '[{}]'.format(utils.get_shortish_repr(key))
+
+    def _get_value(self, main_value, key):
+        return main_value[key]
+
+
+class Indices(Keys):
+    _slice = slice(None)
+
+    def _keys(self, main_value):
+        return range(len(main_value))[self._slice]
+
+    def __getitem__(self, item):
+        assert isinstance(item, slice)
+        result = deepcopy(self)
+        result._slice = item
+        return result
+
+
+class Exploding(BaseVariable):
+    def _items(self, main_value):
+        if isinstance(main_value, Mapping):
+            cls = Keys
+        elif isinstance(main_value, Sequence):
+            cls = Indices
+        else:
+            cls = Attrs
+
+        return cls(self.source, self.exclude)._items(main_value)


Test patch:
diff --git a/tests/test_pysnooper.py b/tests/test_pysnooper.py
index 734296e..10d3620 100644
--- a/tests/test_pysnooper.py
+++ b/tests/test_pysnooper.py
@@ -3,14 +3,12 @@
 
 import io
 import textwrap
+import types
 
-from python_toolbox import sys_tools
-from python_toolbox import temp_file_tools
-from pysnooper.third_party import six
+from python_toolbox import sys_tools, temp_file_tools
 import pytest
 
 import pysnooper
-from pysnooper.third_party import six
 from .utils import (assert_output, VariableEntry, CallEntry, LineEntry,
                     ReturnEntry, OpcodeEntry, ReturnValueEntry, ExceptionEntry)
 
@@ -76,7 +74,7 @@ def test_callable():
 
 
 
-def test_variables():
+def test_watch():
 
     class Foo(object):
         def __init__(self):
@@ -85,7 +83,7 @@ def test_variables():
         def square(self):
             self.x **= 2
 
-    @pysnooper.snoop(variables=(
+    @pysnooper.snoop(watch=(
             'foo.x',
             'io.__name__',
             'len(foo.__dict__["x"] * "abc")',
@@ -127,7 +125,103 @@ def test_variables():
     )
 
 
-def test_single_variable_no_comma():
+def test_watch_explode():
+    class Foo:
+        def __init__(self, x, y):
+            self.x = x
+            self.y = y
+
+
+    @pysnooper.snoop(watch_explode=('_d', '_point', 'lst'))
+    def my_function():
+        _d = {'a': 1, 'b': 2, 'c': 'ignore'}
+        _point = Foo(x=3, y=4)
+        lst = [7, 8, 9]
+        lst.append(10)
+
+    with sys_tools.OutputCapturer(stdout=False,
+                                  stderr=True) as output_capturer:
+        result = my_function()
+    assert result is None
+    output = output_capturer.string_io.getvalue()
+    assert_output(
+        output,
+        (
+            VariableEntry('Foo'),
+            CallEntry('def my_function():'),
+            LineEntry(),
+            VariableEntry("(_d)['a']", '1'),
+            VariableEntry("(_d)['b']", '2'),
+            VariableEntry("(_d)['c']", "'ignore'"),
+            VariableEntry('_d'),
+            LineEntry(),
+            VariableEntry('(_point).x', '3'),
+            VariableEntry('(_point).y', '4'),
+            VariableEntry('_point'),
+            LineEntry(),
+            VariableEntry('(lst)[0]', '7'),
+            VariableEntry('(lst)[1]', '8'),
+            VariableEntry('(lst)[2]', '9'),
+            VariableEntry('lst'),
+            LineEntry(),
+            VariableEntry('(lst)[3]', '10'),
+            VariableEntry('lst'),
+            ReturnEntry(),
+            ReturnValueEntry('None')
+        )
+    )
+
+
+def test_variables_classes():
+    class WithSlots(object):
+        __slots__ = ('x', 'y')
+
+        def __init__(self):
+            self.x = 3
+            self.y = 4
+
+    @pysnooper.snoop(watch=(
+            pysnooper.Keys('_d', exclude='c'),
+            pysnooper.Attrs('_d'),  # doesn't have attributes
+            pysnooper.Attrs('_s'),
+            pysnooper.Indices('_lst')[-3:],
+    ))
+    def my_function():
+        _d = {'a': 1, 'b': 2, 'c': 'ignore'}
+        _s = WithSlots()
+        _lst = list(range(1000))
+
+    with sys_tools.OutputCapturer(stdout=False,
+                                  stderr=True) as output_capturer:
+        result = my_function()
+    assert result is None
+    output = output_capturer.string_io.getvalue()
+    assert_output(
+        output,
+        (
+            VariableEntry('WithSlots'),
+            CallEntry('def my_function():'),
+            LineEntry(),
+            VariableEntry("(_d)['a']", '1'),
+            VariableEntry("(_d)['b']", '2'),
+            VariableEntry('_d'),
+            LineEntry(),
+            VariableEntry('(_s).x', '3'),
+            VariableEntry('(_s).y', '4'),
+            VariableEntry('_s'),
+            LineEntry(),
+            VariableEntry('(_lst)[997]', '997'),
+            VariableEntry('(_lst)[998]', '998'),
+            VariableEntry('(_lst)[999]', '999'),
+            VariableEntry('_lst'),
+            ReturnEntry(),
+            ReturnValueEntry('None')
+        )
+    )
+
+
+
+def test_single_watch_no_comma():
 
     class Foo(object):
         def __init__(self):
@@ -136,7 +230,7 @@ def test_single_variable_no_comma():
         def square(self):
             self.x **= 2
 
-    @pysnooper.snoop(variables='foo')
+    @pysnooper.snoop(watch='foo')
     def my_function():
         foo = Foo()
         for i in range(2):
@@ -282,7 +376,7 @@ def test_method_and_prefix():
         def __init__(self):
             self.x = 2
 
-        @pysnooper.snoop(variables=('self.x',), prefix='ZZZ')
+        @pysnooper.snoop(watch=('self.x',), prefix='ZZZ')
         def square(self):
             foo = 7
             self.x **= 2
diff --git a/tests/utils.py b/tests/utils.py
index c4947b7..0217ace 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -31,6 +31,20 @@ class _BaseEntry(pysnooper.pycompat.ABC):
     def check(self, s):
         pass
 
+    def __repr__(self):
+        init_arguments = get_function_arguments(self.__init__,
+                                                exclude=('self',))
+        attributes = {
+            key: repr(getattr(self, key)) for key in init_arguments
+                                              if getattr(self, key) is not None
+        }
+        return '%s(%s)' % (
+            type(self).__name__,
+            ', '.join('{key}={value}'.format(**locals()) for key, value
+                                                         in attributes.items())
+        )
+
+
 
 class _BaseValueEntry(_BaseEntry):
     def __init__(self, prefix=''):
@@ -56,19 +70,6 @@ class _BaseValueEntry(_BaseEntry):
         return (self._check_preamble(preamble) and
                                                   self._check_content(content))
 
-    def __repr__(self):
-        init_arguments = get_function_arguments(self.__init__,
-                                                exclude=('self',))
-        attributes = {
-            key: repr(getattr(self, key)) for key in init_arguments
-                                              if getattr(self, key) is not None
-        }
-        return '%s(%s)' % (
-            type(self).__name__,
-            ', '.join('{key}={value}'.format(**locals()) for key, value
-                                                         in attributes.items())
-        )
-
 
 class VariableEntry(_BaseValueEntry):
     def __init__(self, name=None, value=None, stage=None, prefix='',