Repository: scoutapp/scout_apm_python (python)

Issue:
capture_backtrace raises AttributeError on PEP-420 namespace packages
The new `capture_backtrace` function in `scout_apm.core.backtrace` raises an AttributeError when the stack includes a [PEP-420] namespace package.

This is caused by the [`module_filepath` function](https://github.com/scoutapp/scout_apm_python/blob/v2.21.0/src/scout_apm/core/backtrace.py#L26-L33), specifically line 32:

```python
    module_dir = sys.modules[root_module].__file__.rsplit(os.sep, 2)[0]
```

If `sys.modules[root_module]` is a [PEP-420] namespace package, this will raise
```
AttributeError: 'NoneType' object has no attribute 'rsplit'
```

### Steps to reproduce

Create a namespace package, with some modules inside, e.g.:
```
namespace/
    foo/
        __init__.py
    bar/
       __init__.py
```

Then on an interactive Python shell:

```
>>> from scout_apm.core.backtrace import module_filepath
>>> from namespace import foo
>>> module_filepath("namespace.foo", "namespace")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/jack/venvs/tmp-a17ac7185189989/lib/python3.8/site-packages/scout_apm/core/backtrace.py", line 32, in module_filepath
    module_dir = sys.modules[root_module].__file__.rsplit(os.sep, 2)[0]
AttributeError: 'NoneType' object has no attribute 'rsplit'
```

### Details

- Tested with version 2.21.0
- Current workaround is to pin version to 2.20.0

[PEP-420]: https://www.python.org/dev/peps/pep-0420/

Pull request description:
Handle case of namespace packages when capturing backtraces.
A namespace package does not have a file property. Instead utilize
the __path__ attribute and use the first element in the list. While
this may not capture the dynamic cases, it should capture the
necessary data for most cases.

Fixes #667

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83b396b..bf23d81 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@
 ### Added
 
 ### Fixed
+- Handle case of namespace packages when capturing backtraces.
+  ([Issue #667](https://github.com/scoutapp/scout_apm_python/issues/667))
 
 ## [2.21.0] 2021-07-23
 
diff --git a/src/scout_apm/core/backtrace.py b/src/scout_apm/core/backtrace.py
index 52c4be1..b36bead 100644
--- a/src/scout_apm/core/backtrace.py
+++ b/src/scout_apm/core/backtrace.py
@@ -7,6 +7,9 @@ import sys
 import sysconfig
 import traceback
 import warnings
+from logging import getLogger
+
+logger = getLogger(__name__)
 
 # Maximum non-Scout frames to target retrieving
 LIMIT = 50
@@ -25,11 +28,25 @@ def filter_frames(frames):
 
 def module_filepath(module, filepath):
     """Get the filepath relative to the base module."""
-    root_module = module.split(".", 1)[0]
-    if root_module == module:
+    root_module_name = module.split(".", 1)[0]
+    if root_module_name == module:
         return os.path.basename(filepath)
 
-    module_dir = sys.modules[root_module].__file__.rsplit(os.sep, 2)[0]
+    root_module = sys.modules[root_module_name]
+    if root_module.__file__:
+        module_dir = root_module.__file__.rsplit(os.sep, 2)[0]
+    elif root_module.__path__:
+        # Default to using the first path specified for the module.
+        module_dir = root_module.__path__[0].rsplit(os.sep, 1)[0]
+        if len(root_module.__path__) > 1:
+            logger.debug(
+                "{} has {} paths. Use the first and ignore the rest.".format(
+                    root_module, len(root_module.__path__)
+                )
+            )
+    else:
+        # If the file path don't exist, then return the full path.
+        return filepath
     return filepath.split(module_dir, 1)[-1].lstrip(os.sep)
 
 


Test patch:
diff --git a/tests/unit/core/test_backtrace.py b/tests/unit/core/test_backtrace.py
index fa4c3a4..682a0d0 100644
--- a/tests/unit/core/test_backtrace.py
+++ b/tests/unit/core/test_backtrace.py
@@ -5,9 +5,20 @@ import os
 import sys
 import sysconfig
 
+import pytest
+
 from scout_apm.core import backtrace
 
 
+def get_tb():
+    # Python 2 compatible way of getting the current frame.
+    try:
+        raise ZeroDivisionError
+    except ZeroDivisionError:
+        # Get the current frame
+        return sys.exc_info()[2]
+
+
 def test_capture_backtrace():
     stack = backtrace.capture_backtrace()
 
@@ -54,15 +65,7 @@ def test_filter_frames():
 
 
 def test_capture_stacktrace():
-    def error():
-        # Python 2 compatible way of getting the current frame.
-        try:
-            raise ZeroDivisionError
-        except ZeroDivisionError:
-            # Get the current frame
-            return sys.exc_info()[2]
-
-    stack = backtrace.capture_stacktrace(error())
+    stack = backtrace.capture_stacktrace(get_tb())
 
     assert len(stack) == 1
     for frame in stack:
@@ -73,4 +76,52 @@ def test_capture_stacktrace():
         assert isinstance(frame["function"], str)
 
     assert stack[0]["file"] == "tests/unit/core/test_backtrace.py"
-    assert stack[0]["function"] == "error"
+    assert stack[0]["function"] == "get_tb"
+
+
+def test_module_filepath():
+    frame = get_tb().tb_frame
+    module = frame.f_globals["__name__"]
+    filepath = frame.f_code.co_filename
+    assert (
+        backtrace.module_filepath(module, filepath)
+        == "tests/unit/core/test_backtrace.py"
+    )
+
+
+@pytest.fixture
+def test_package_as_namespace_package():
+    original = sys.modules["tests"].__file__
+    sys.modules["tests"].__file__ = None
+    yield
+    sys.modules["tests"].__file__ = original
+
+
+def test_module_filepath_with_namespace(test_package_as_namespace_package):
+    frame = get_tb().tb_frame
+    module = frame.f_globals["__name__"]
+    filepath = frame.f_code.co_filename
+    assert (
+        backtrace.module_filepath(module, filepath)
+        == "tests/unit/core/test_backtrace.py"
+    )
+
+
+@pytest.fixture
+def test_package_no_file_or_path():
+    orig_file = sys.modules["tests"].__file__
+    orig_path = sys.modules["tests"].__path__
+    sys.modules["tests"].__file__ = None
+    sys.modules["tests"].__path__ = None
+    yield
+    sys.modules["tests"].__file__ = orig_file
+    sys.modules["tests"].__path__ = orig_path
+
+
+def test_module_filepath_without_file_or_path(test_package_no_file_or_path):
+    frame = get_tb().tb_frame
+    module = frame.f_globals["__name__"]
+    filepath = frame.f_code.co_filename
+    full_path = backtrace.module_filepath(module, filepath)
+    assert full_path != "tests/unit/core/test_backtrace.py"
+    assert full_path.endswith("tests/unit/core/test_backtrace.py")