Repository: scoutapp/scout_apm_python
Pull request #204 (2019-07-02 09:35:26)

Tidy API and add tests for compat
* Move 'text' into 'compat'
* Add `__all__` in `api` so it's clearer what is intended to be the public API
* Flatten the two Context classes into one, since it's only part of the API and used there now
* Use `@classmethod` instead of `@staticmethod` for `Context`, which is slightly more pythonic (even moreso would be a plain function)
* Add tests for compat, and update implementation of `datetime_to_timestamp` to be consistent on both Pythons 2 and 3
* Rewrite API tests to use `tracked_request` fixture and thus be smaller


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b453f65..cde1149 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,8 @@
 - The undocumented `core-agent-manager` CLI command works again
 - Consistently track view responses on Django between different versions
 - Avoid unbalanced request tracking in certain cases on Django < 1.10
+- Clarified contents of public API by moving some stuff out of it and setting
+  `scout_apm.api.__all__`
 
 ## [2.1.0] 2019-06-25
 
diff --git a/src/scout_apm/api/__init__.py b/src/scout_apm/api/__init__.py
index 817cdcf..99ecb1a 100644
--- a/src/scout_apm/api/__init__.py
+++ b/src/scout_apm/api/__init__.py
@@ -4,26 +4,35 @@ from __future__ import absolute_import, division, print_function, unicode_litera
 import logging
 
 import scout_apm.core
-from scout_apm.api.context import Context as ScoutContext
-from scout_apm.compat import ContextDecorator, text_type
+from scout_apm.compat import ContextDecorator, text
 from scout_apm.core.config import ScoutConfig
 from scout_apm.core.tracked_request import TrackedRequest
 
 logger = logging.getLogger(__name__)
 
 
-def text(value, encoding="utf-8", errors="strict"):
-    """Convert a value to str on Python 3 and unicode on Python 2."""
-    if isinstance(value, text_type):
-        return value
-    elif isinstance(value, bytes):
-        return text_type(value, encoding, errors)
-    else:
-        return text_type(value)
+__all__ = [
+    "BackgroundTransaction",
+    "Config",
+    "Context",
+    "WebTransaction",
+    "install",
+    "instrument",
+]
 
 
-class Context(ScoutContext):
-    pass
+class Context(object):
+    @classmethod
+    def add(self, key, value):
+        """Adds context to the currently executing request.
+
+        :key: Any String identifying the request context.
+              Example: "user_ip", "plan", "alert_count"
+        :value: Any json-serializable type.
+              Example: "1.1.1.1", "free", 100
+        :returns: nothing.
+        """
+        TrackedRequest.instance().tag(key, value)
 
 
 class Config(ScoutConfig):
diff --git a/src/scout_apm/api/context.py b/src/scout_apm/api/context.py
deleted file mode 100644
index bd39e1e..0000000
--- a/src/scout_apm/api/context.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# coding=utf-8
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-from scout_apm.core.tracked_request import TrackedRequest
-
-
-class Context(object):
-    @staticmethod
-    def add(key, value):
-        """Adds context to the currently executing request.
-
-        :key: Any String identifying the request context.
-              Example: "user_ip", "plan", "alert_count"
-        :value: Any json-serializable type.
-              Example: "1.1.1.1", "free", 100
-        :returns: nothing.
-        """
-        tr = TrackedRequest.instance()
-        tr.tag(key, value)
diff --git a/src/scout_apm/compat.py b/src/scout_apm/compat.py
index a44f39c..664dffb 100644
--- a/src/scout_apm/compat.py
+++ b/src/scout_apm/compat.py
@@ -1,8 +1,8 @@
 # coding=utf-8
 from __future__ import absolute_import, division, print_function, unicode_literals
 
+import datetime as dt
 import sys
-import time
 
 string_type = str if sys.version_info[0] >= 3 else basestring  # noqa: F821
 text_type = str if sys.version_info[0] >= 3 else unicode  # noqa: F821
@@ -31,17 +31,30 @@ except ImportError:
     # Python 2.x
     import Queue as queue
 
-
+# datetime_to_timestamp converts a naive UTC datetime to a unix timestamp
 if sys.version_info >= (3, 3):
 
     def datetime_to_timestamp(datetime_obj):
-        return datetime_obj.timestamp()
+        return datetime_obj.replace(tzinfo=dt.timezone.utc).timestamp()
 
 
 else:
+    _EPOCH = dt.datetime(1970, 1, 1)
 
     def datetime_to_timestamp(datetime_obj):
-        return time.mktime(datetime_obj.timetuple())
+        return (datetime_obj - _EPOCH).total_seconds()
+
+
+def text(value, encoding="utf-8", errors="strict"):
+    """
+    Convert a value to str on Python 3 and unicode on Python 2.
+    """
+    if isinstance(value, text_type):
+        return value
+    elif isinstance(value, bytes):
+        return text_type(value, encoding, errors)
+    else:
+        return text_type(value)
 
 
 __all__ = [
@@ -49,5 +62,6 @@ __all__ = [
     "datetime_to_timestamp",
     "queue",
     "string_type",
+    "text",
     "text_type",
 ]


Test patch:
diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py
index d1b852b..c38e021 100644
--- a/tests/integration/test_api.py
+++ b/tests/integration/test_api.py
@@ -1,5 +1,4 @@
-# coding: utf-8
-
+# coding=utf-8
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 from scout_apm.api import (
@@ -10,102 +9,84 @@ from scout_apm.api import (
     ignore_transaction,
     instrument,
 )
-from scout_apm.core.tracked_request import TrackedRequest
-
 
-def test_instrument_context_manager():
-    # Save TR here, so it doesn't disappear on us when span finishes
-    tr = TrackedRequest.instance()
 
+def test_instrument_context_manager(tracked_request):
     with instrument("Test ContextMgr") as inst:
         inst.tag("foo", "bar")
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Custom/Test ContextMgr"
-
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Custom/Test ContextMgr"
 
-def test_instrument_decorator():
-    # Save TR here, so it doesn't disappear on us when span finishes
-    tr = TrackedRequest.instance()
 
+def test_instrument_decorator(tracked_request):
     @instrument("Test Decorator")
-    def test():
+    def example():
         pass
 
-    test()
+    example()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Custom/Test Decorator"
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Custom/Test Decorator"
 
 
-def test_instrument_context_manager_with_kind():
-    # Save TR here, so it doesn't disappear on us when span finishes
-    tr = TrackedRequest.instance()
-
+def test_instrument_context_manager_with_kind(tracked_request):
     with instrument("Get", kind="Redis") as inst:
         inst.tag("foo", "bar")
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Redis/Get"
-
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Redis/Get"
 
-def test_instrument_decorator_with_kind():
-    # Save TR here, so it doesn't disappear on us when span finishes
-    tr = TrackedRequest.instance()
 
+def test_instrument_decorator_with_kind(tracked_request):
     @instrument("GET example.com", kind="HTTP")
-    def test():
+    def example():
         pass
 
-    test()
-
-    span = tr.complete_spans[-1]
-    assert span.operation == "HTTP/GET example.com"
+    example()
 
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "HTTP/GET example.com"
 
-def test_instrument_context_manager_default_tags():
-    # Save TR here, so it doesn't disappear on us when span finishes
-    tr = TrackedRequest.instance()
 
+def test_instrument_context_manager_default_tags(tracked_request):
     with instrument("tag test", tags={"x": 99}):
         pass
 
-    span = tr.complete_spans[-1]
-    assert span.tags["x"] == 99
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].tags["x"] == 99
 
 
-def test_instrument_decorator_default_tags():
-    # Save TR here, so it doesn't disappear on us when span finishes
-    tr = TrackedRequest.instance()
-
+def test_instrument_decorator_default_tags(tracked_request):
     @instrument("tag test", tags={"x": 99})
-    def test():
+    def example():
         pass
 
-    test()
-
-    span = tr.complete_spans[-1]
-    assert span.tags["x"] == 99
+    example()
 
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].tags["x"] == 99
 
-def test_instrument_non_ascii_params():
-    # Save TR here, so it doesn't disappear on us when span finishes
-    tr = TrackedRequest.instance()
 
+def test_instrument_non_ascii_params(tracked_request):
     @instrument(operation="Faire le café", kind="Personnalisé")
     def make_coffee():
         pass
 
     make_coffee()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Personnalisé/Faire le café"
-
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Personnalisé/Faire le café"
 
-def test_instrument_non_ascii_bytes_params():
-    # Save TR here, so it doesn't disappear on us when span finishes
-    tr = TrackedRequest.instance()
 
+def test_instrument_non_ascii_bytes_params(tracked_request):
     # On Python 2, user code that doesn't enable unicode_literals may contain
     # non-ASCII bystrings. For writing a test that works across Python versions,
     # the easiest is to create bytestrings by encoding unicode strings.
@@ -117,141 +98,129 @@ def test_instrument_non_ascii_bytes_params():
 
     make_coffee()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Personnalisé/Faire le café"
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Personnalisé/Faire le café"
 
 
-def test_web_transaction_start_stop():
-    tr = TrackedRequest.instance()
-
+def test_web_transaction_start_stop(tracked_request):
     WebTransaction.start("Foo")
     WebTransaction.stop()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Controller/Foo"
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Controller/Foo"
 
 
-def test_web_transaction_context_manager():
-    tr = TrackedRequest.instance()
+def test_web_transaction_context_manager(tracked_request):
     x = 0
 
     with WebTransaction("Foo"):
         x = 1
 
-    span = tr.complete_spans[-1]
     assert x == 1
-    assert span.operation == "Controller/Foo"
-
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Controller/Foo"
 
-def test_web_transaction_decorator():
-    tr = TrackedRequest.instance()
 
+def test_web_transaction_decorator(tracked_request):
     @WebTransaction("Bar")
     def my_transaction():
         pass
 
     my_transaction()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Controller/Bar"
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Controller/Bar"
 
 
-def test_web_transaction_non_ascii_params():
-    tr = TrackedRequest.instance()
-
+def test_web_transaction_non_ascii_params(tracked_request):
     @WebTransaction("Acheter du café")
     def buy_coffee():
         pass
 
     buy_coffee()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Controller/Acheter du café"
-
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Controller/Acheter du café"
 
-def test_web_transaction_non_ascii_bytes_params():
-    tr = TrackedRequest.instance()
 
+def test_web_transaction_non_ascii_bytes_params(tracked_request):
     @WebTransaction("Acheter du café".encode("utf-8"))
     def buy_coffee():
         pass
 
     buy_coffee()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Controller/Acheter du café"
-
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Controller/Acheter du café"
 
-def test_background_transaction_start_stop():
-    tr = TrackedRequest.instance()
 
+def test_background_transaction_start_stop(tracked_request):
     BackgroundTransaction.start("Foo")
     BackgroundTransaction.stop()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Job/Foo"
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Job/Foo"
 
 
-def test_background_transaction_context_manager():
-    tr = TrackedRequest.instance()
+def test_background_transaction_context_manager(tracked_request):
     x = 0
 
     with BackgroundTransaction("Foo"):
         x = 1
 
-    span = tr.complete_spans[-1]
     assert x == 1
-    assert span.operation == "Job/Foo"
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Job/Foo"
 
 
-def test_background_transaction_decorator():
-    tr = TrackedRequest.instance()
-
+def test_background_transaction_decorator(tracked_request):
     @BackgroundTransaction("Bar")
     def my_transaction():
         pass
 
     my_transaction()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Job/Bar"
-
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Job/Bar"
 
-def test_background_transaction_non_ascii_params():
-    tr = TrackedRequest.instance()
 
+def test_background_transaction_non_ascii_params(tracked_request):
     @BackgroundTransaction("Acheter du café")
     def buy_coffee():
         pass
 
     buy_coffee()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Job/Acheter du café"
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Job/Acheter du café"
 
 
-def test_background_transaction_non_ascii_bytes_params():
-    tr = TrackedRequest.instance()
-
+def test_background_transaction_non_ascii_bytes_params(tracked_request):
     @BackgroundTransaction("Acheter du café".encode("utf-8"))
     def buy_coffee():
         pass
 
     buy_coffee()
 
-    span = tr.complete_spans[-1]
-    assert span.operation == "Job/Acheter du café"
-
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Job/Acheter du café"
 
-def test_context():
-    # Save TR here, so it doesn't disappear on us when span finishes
-    tr = TrackedRequest.instance()
 
+def test_context(tracked_request):
     Context.add("x", 99)
 
-    tr.finish()
-
-    assert tr.tags["x"] == 99
+    assert tracked_request.tags["x"] == 99
 
 
 def test_config():
@@ -261,11 +230,7 @@ def test_config():
         Config.reset_all()
 
 
-def test_ignore_transaction():
-    tr = TrackedRequest.instance()
-
+def test_ignore_transaction(tracked_request):
     ignore_transaction()
 
-    tr.finish()
-
-    assert tr.tags["ignore_transaction"]
+    assert tracked_request.tags["ignore_transaction"]
diff --git a/tests/unit/test_compat.py b/tests/unit/test_compat.py
new file mode 100644
index 0000000..9294db1
--- /dev/null
+++ b/tests/unit/test_compat.py
@@ -0,0 +1,50 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import datetime as dt
+
+import pytest
+
+from scout_apm.compat import ContextDecorator, datetime_to_timestamp, text
+
+
+def test_context_decorator():
+    class MyCD(ContextDecorator):
+        def __init__(self):
+            self.called = False
+
+        def __enter__(self):
+            self.called = True
+
+        def __exit__(self, *exc):
+            pass
+
+    my_cd = MyCD()
+
+    @my_cd
+    def example():
+        pass
+
+    example()
+
+    assert my_cd.called
+
+
+@pytest.mark.parametrize(
+    "given, expected",
+    [
+        (dt.datetime(1970, 1, 1), 0),
+        (dt.datetime(1970, 1, 1, 1), 3600.0),
+        (dt.datetime(1970, 1, 1, 0, 30), 1800.0),
+        (dt.datetime(2019, 7, 2, 9, 23, 41), 1562059421.0),
+    ],
+)
+def test_datetime_to_timestamp(given, expected):
+    assert datetime_to_timestamp(given) == expected
+
+
+@pytest.mark.parametrize(
+    "given, expected", [("foo", "foo"), (b"foo", "foo"), ([], "[]")]
+)
+def test_text(given, expected):
+    assert text(given) == expected