Repository: itamarst/eliot

Issue:
Make eliot._parse public so eliot-tree isn't depending on private API
`eliot-tree` depends on `eliot._parse`, which is private. Really, it's a stable API, and it should be useful to others as well.

So:

1. Make it public.
2. Add backwards compat `eliot._parse` for old eliot-tree installs.
3. Document it, at least a little.

CC @jonathanj 

Comments:

Patch:
diff --git a/docs/source/news.rst b/docs/source/news.rst
index bb2ba9b..b0409df 100644
--- a/docs/source/news.rst
+++ b/docs/source/news.rst
@@ -13,6 +13,7 @@ Features:
 * NumPy integers, floats, bools and arrays are now automatically serialized to JSON, via a new default JSON encoder (``eliot.json.EliotJSONEncoder``).
 * Dask support: replace ``dask.compute()`` with ``eliot.dask.compute_with_trace()`` to automatically preserve Eliot context for ``Bag`` and ``Delayed`` Dask computations. See :ref:`dask_usage` for details.
 * New decorator, ``@eliot.log_call``, which automatically creates an action that starts when function is called and ends when it returns. See :ref:`log_call decorator`.
+* A parser library for parsing serialized Eliot JSON messages into a tree of Python objects. See :ref:`parsing_logs` for details.
 
 Testing features:
 
diff --git a/docs/source/reading/reading.rst b/docs/source/reading/reading.rst
index 1312135..89a0ab9 100644
--- a/docs/source/reading/reading.rst
+++ b/docs/source/reading/reading.rst
@@ -41,3 +41,24 @@ For example, here's how you'd extract a particular field with `jq`_:
 
 .. _eliot-tree: https://github.com/jonathanj/eliottree
 .. _jq: https://stedolan.github.io/jq/
+
+
+.. _parsing_logs:
+
+Parsing Logs
+------------
+
+Eliot also includes a parser for parsing logs into Python objects:
+
+.. code-block:: python
+
+   import json
+   from eliot.parse import Parser
+
+   def load_messages(logfile_path):
+      for line in open(logfile_path):
+          yield json.loads(line)
+       
+   def parse(logfile_path):
+       for task in Parser.parse_stream(load_messages(logfile_path)):
+           print("Root action type is", task.root().action_type)
diff --git a/eliot/__init__.py b/eliot/__init__.py
index 2d998fc..d21ecbb 100644
--- a/eliot/__init__.py
+++ b/eliot/__init__.py
@@ -47,6 +47,18 @@ add_destinations = Logger._destinations.add
 remove_destination = removeDestination
 add_global_fields = addGlobalFields
 
+# Backwards compatibility for old versions of eliot-tree, which rely on
+# eliot._parse:
+def _parse_compat():
+    # Force eliot.parse to be imported in way that works with old Python:
+    from .parse import Parser
+    del Parser
+    import sys
+    sys.modules["eliot._parse"] = sys.modules["eliot.parse"]
+    return sys.modules["eliot.parse"]
+_parse = _parse_compat()
+del _parse_compat
+
 
 __all__ = [
     "Message",
@@ -83,7 +95,11 @@ __all__ = [
     "to_file",
     "log_call",
     "__version__",
+
+    # Backwards compat for eliot-tree:
+    "_parse",
 ]
 
+
 __version__ = get_versions()['version']
 del get_versions
diff --git a/eliot/_action.py b/eliot/_action.py
index b15c050..9549ca2 100644
--- a/eliot/_action.py
+++ b/eliot/_action.py
@@ -23,7 +23,7 @@ from pyrsistent import (
     pvector_field,
     pvector, )
 from boltons.funcutils import wraps
-from six import text_type as unicode, integer_types
+from six import text_type as unicode, integer_types, PY3
 
 from ._message import (
     Message,
@@ -896,12 +896,29 @@ def log_call(
                        include_result=include_result)
 
     if action_type is None:
-        action_type = wrapped_function.__name__
+        if PY3:
+            action_type = "{}.{}".format(wrapped_function.__module__,
+                                         wrapped_function.__qualname__)
+        else:
+            action_type = wrapped_function.__name__
+
+    if PY3 and include_args is not None:
+        from inspect import signature
+        sig = signature(wrapped_function)
+        if set(include_args) - set(sig.parameters):
+            raise ValueError(
+                ("include_args ({}) lists arguments not in the "
+                 "wrapped function").format(include_args)
+            )
 
     @wraps(wrapped_function)
     def logging_wrapper(*args, **kwargs):
         callargs = getcallargs(wrapped_function, *args, **kwargs)
 
+        # Remove self is it's included:
+        if "self" in callargs:
+            callargs.pop("self")
+
         # Filter arguments to log, if necessary:
         if include_args is not None:
             callargs = {k: callargs[k] for k in include_args}
diff --git a/eliot/_parse.py b/eliot/parse.py
similarity index 98%
rename from eliot/_parse.py
rename to eliot/parse.py
index 91474d0..bca580c 100644
--- a/eliot/_parse.py
+++ b/eliot/parse.py
@@ -181,3 +181,6 @@ class Parser(PClass):
                 yield task
         for task in parser.incomplete_tasks():
             yield task
+
+
+__all__ = ["Parser", "Task", "TaskLevel", "WrittenMessage", "WrittenAction"]


Test patch:
diff --git a/eliot/tests/corotests.py b/eliot/tests/corotests.py
index de844c8..78e3501 100644
--- a/eliot/tests/corotests.py
+++ b/eliot/tests/corotests.py
@@ -10,7 +10,7 @@ from threading import Thread
 from unittest import TestCase
 
 from ..testing import capture_logging
-from .._parse import Parser
+from ..parse import Parser
 from .. import start_action
 from .._action import _ExecutionContext, _context, use_asyncio_context
 from .._asyncio import AsyncioContext
diff --git a/eliot/tests/test_action.py b/eliot/tests/test_action.py
index 86eb5e1..dc1113b 100644
--- a/eliot/tests/test_action.py
+++ b/eliot/tests/test_action.py
@@ -5,7 +5,7 @@ Tests for L{eliot._action}.
 from __future__ import unicode_literals
 
 import pickle
-from unittest import TestCase
+from unittest import TestCase, skipIf
 from threading import Thread
 from warnings import catch_warnings, simplefilter
 
@@ -44,7 +44,7 @@ from .._message import (
 from .._output import MemoryLogger
 from .._validation import ActionType, Field, _ActionSerializers
 from ..testing import assertContainsFields, capture_logging
-from .._parse import Parser
+from ..parse import Parser
 from .. import (
     _action,
     add_destination,
@@ -1655,6 +1655,9 @@ class LogCallTests(TestCase):
 
     def assert_logged(self, logger, action_type, expected_params, expected_result):
         """Assert that an action of given structure was logged."""
+        if six.PY2:
+            # On Python 2 we don't include the module or class:
+            action_type = action_type.split(".")[-1]
         [tree] = Parser.parse_stream(logger.messages)
         root = tree.root()
         self.assertEqual(root.action_type, action_type)
@@ -1676,7 +1679,7 @@ class LogCallTests(TestCase):
             return 4
 
         myfunc(2, 3)
-        self.assert_logged(logger, u"myfunc",
+        self.assert_logged(logger, self.id() + ".<locals>.myfunc",
                            {u"x": 2, u"y": 3}, 4)
 
     @capture_logging(None)
@@ -1712,7 +1715,7 @@ class LogCallTests(TestCase):
             return 4
 
         myfunc(2, y=5)
-        self.assert_logged(logger, u"myfunc",
+        self.assert_logged(logger, self.id() + ".<locals>.myfunc",
                            {u"x": 2, u"y": 5}, 4)
 
     @capture_logging(None)
@@ -1723,7 +1726,7 @@ class LogCallTests(TestCase):
             return 6
 
         myfunc(2)
-        self.assert_logged(logger, u"myfunc",
+        self.assert_logged(logger, self.id() + ".<locals>.myfunc",
                            {u"x": 2, u"y": 1}, 6)
 
     @capture_logging(None)
@@ -1734,7 +1737,7 @@ class LogCallTests(TestCase):
             return 6
 
         myfunc(2, 3, 4, a=1, b=2)
-        self.assert_logged(logger, u"myfunc",
+        self.assert_logged(logger, self.id() + ".<locals>.myfunc",
                            {u"x": 2, u"y": (3, 4), u"z": {u"a": 1, u"b": 2}},
                            6)
 
@@ -1746,7 +1749,16 @@ class LogCallTests(TestCase):
             return 6
 
         myfunc(2, 3, 4)
-        self.assert_logged(logger, u"myfunc", {u"x": 2, u"z": 4}, 6)
+        self.assert_logged(logger, self.id() + ".<locals>.myfunc",
+                           {u"x": 2, u"z": 4}, 6)
+
+    @skipIf(six.PY2, "Didn't bother implementing safety check on Python 2")
+    def test_wrong_whitelist_args(self):
+        """If C{include_args} doesn't match function, raise an exception."""
+        with self.assertRaises(ValueError):
+            @log_call(include_args=["a", "x", "y"])
+            def f(x, y):
+                pass
 
     @capture_logging(None)
     def test_no_result(self, logger):
@@ -1771,3 +1783,16 @@ class LogCallTests(TestCase):
             for_pickling,
             pickle.loads(pickle.dumps(for_pickling))
         )
+
+    @capture_logging(None)
+    def test_methods(self, logger):
+        """self is not logged."""
+        class C(object):
+            @log_call
+            def f(self, x):
+                pass
+
+        C().f(2)
+        self.assert_logged(logger, self.id() + u".<locals>.C.f", {u"x": 2}, None)
+
+
diff --git a/eliot/tests/test_parse.py b/eliot/tests/test_parse.py
index a24ad00..77410ee 100644
--- a/eliot/tests/test_parse.py
+++ b/eliot/tests/test_parse.py
@@ -16,7 +16,7 @@ from pyrsistent import PClass, field, pvector_field
 
 from .. import start_action, Message
 from ..testing import MemoryLogger
-from .._parse import Task, Parser
+from ..parse import Task, Parser
 from .._message import (
     WrittenMessage,
     MESSAGE_TYPE_FIELD,
@@ -295,3 +295,15 @@ class ParserTests(TestCase):
                 if m is not None]))
         assertCountEqual(
             self, all_tasks, [parse_to_task(msgs) for msgs in all_messages])
+
+
+class BackwardsCompatibility(TestCase):
+    """Tests for backwards compatibility."""
+
+    def test_imports(self):
+        """Old ways of importing still work."""
+        import eliot._parse
+        from eliot import _parse
+        import eliot.parse
+        self.assertIs(eliot.parse, eliot._parse)
+        self.assertIs(_parse, eliot.parse)