Repository: scoutapp/scout_apm_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/


Discussion:
Hi Jack,

Thank you for the detailed issue. I'm looking into it.

-Tim

Resolving patch:
diff --git a/src/scout_apm/core/backtrace.py b/src/scout_apm/core/backtrace.py
--- a/src/scout_apm/core/backtrace.py
+++ b/src/scout_apm/core/backtrace.py
@@ -7,6 +7,9 @@
 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
--- a/tests/unit/core/test_backtrace.py
+++ b/tests/unit/core/test_backtrace.py
@@ -5,9 +5,20 @@
 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 error():
         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")