Repository: getsentry/sentry-python

Issue:
Decorator @sentry_sdk.trace is not compatible with Click (edge case)
### Environment

SaaS (https://sentry.io/)

### Steps to Reproduce

Given custom command loader:

```python
class Loader(RichGroup):

    COMMANDS_FOLDER = os.path.join(os.path.dirname(__file__), "commands")

    def list_commands(self, ctx) -> list[str]:
        rv = []
        try:
            for filename in os.listdir(self.COMMANDS_FOLDER):
                if filename.endswith(".py") and filename != "__init__.py":
                    rv.append(filename[:-3])
            rv.sort()
            return rv
        except OSError as e:
            logging.error("Failed to list commands: %s", e)
            return []

    def get_command(self, ctx, name: str) -> click.Command:
        if not name.isalnum():
            raise click.ClickException("Invalid command name")

        ns = {}
        fn = os.path.join(self.COMMANDS_FOLDER, name + ".py")
        logging.debug("Loading %s", fn)

        if not os.path.exists(fn) or not os.path.isfile(fn):
            raise click.ClickException(f"Command '{name}' not found")

        try:
            with open(fn) as f:
                exec(f.read(), ns, ns)
            if "cli" not in ns:
                raise click.ClickException(f"Command '{name}' is invalid: missing 'cli' attribute")
            return ns["cli"]
        except OSError as e:
            logging.exception(e)
            raise click.ClickException(f"Failed to load command '{name}'") from e
        except SyntaxError as e:
            logging.exception(e)
            raise click.ClickException(f"Command '{name}' contains invalid Python code") from e
```

When using decorator on a command loaded by loader:

```python
@click.command(help="...")
@sentry_sdk.trace
def cli() -> None:
    pass
```

## Workaround

`.../lib/python3.10/site-packages/sentry_sdk/utils.py`

```diff
 # Python 3: methods, functions, classes
 if func_qualname is not None:
-    if hasattr(func, "__module__"):
+    if hasattr(func, "__module__") and func.__module__ is not None:
         func_qualname = func.__module__ + "." + func_qualname
     func_qualname = prefix + func_qualname + suffix
```

### Expected Result

No exception is raised.

### Actual Result

Exception is raised:

```
⬢ [Docker] ❯ pc status
Traceback (most recent call last):
  File "/home/vscode/.cache/pypoetry/virtualenvs/partcad-dev-_yoEiCkE-py3.10/bin/pc", line 6, in <module>
    sys.exit(cli())
  File "/home/vscode/.cache/pypoetry/virtualenvs/partcad-dev-_yoEiCkE-py3.10/lib/python3.10/site-packages/rich_click/rich_command.py", line 367, in __call__
    return super().__call__(*args, **kwargs)
  File "/home/vscode/.cache/pypoetry/virtualenvs/partcad-dev-_yoEiCkE-py3.10/lib/python3.10/site-packages/click/core.py", line 1161, in __call__
    return self.main(*args, **kwargs)
  File "/home/vscode/.cache/pypoetry/virtualenvs/partcad-dev-_yoEiCkE-py3.10/lib/python3.10/site-packages/rich_click/rich_command.py", line 152, in main
    rv = self.invoke(ctx)
  File "/home/vscode/.cache/pypoetry/virtualenvs/partcad-dev-_yoEiCkE-py3.10/lib/python3.10/site-packages/click/core.py", line 1697, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/home/vscode/.cache/pypoetry/virtualenvs/partcad-dev-_yoEiCkE-py3.10/lib/python3.10/site-packages/click/core.py", line 1443, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/home/vscode/.cache/pypoetry/virtualenvs/partcad-dev-_yoEiCkE-py3.10/lib/python3.10/site-packages/click/core.py", line 788, in invoke
    return __callback(*args, **kwargs)
  File "/home/vscode/.cache/pypoetry/virtualenvs/partcad-dev-_yoEiCkE-py3.10/lib/python3.10/site-packages/sentry_sdk/tracing_utils.py", line 705, in func_with_tracing
    qualname_from_function(func),
  File "/home/vscode/.cache/pypoetry/virtualenvs/partcad-dev-_yoEiCkE-py3.10/lib/python3.10/site-packages/sentry_sdk/utils.py", line 1505, in qualname_from_function
    func_qualname = func.__module__ + "." + func_qualname
TypeError: unsupported operand type(s) for +: 'NoneType' and 'str'
Sentry is attempting to send 3 pending events
Waiting up to 1 seconds
Press Ctrl-C to quit
```

### Product Area

APIs

### Link

_No response_

### DSN

_No response_

### Version

_No response_

Discussion:
codecov[bot]: ### :x: 1 Tests Failed:
| Tests completed | Failed | Passed | Skipped |
|---|---|---|---|
| 13733 | 1 | 13732 | 4161 |
<details><summary>View the top 1 failed tests by shortest run time</summary>

> 
> ```python
> tests.integrations.httpx.test_httpx test_omit_url_data_if_parsing_fails
> ```
> 
> <details><summary>Stack Traces | 13.2s run time</summary>
> 
> > 
> > ```python
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpx/_transports/default.py:66: in map_httpcore_exceptions
> >     yield
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpx/_transports/default.py:228: in handle_request
> >     resp = self._pool.handle_request(req)
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpcore/_sync/connection_pool.py:256: in handle_request
> >     raise exc from None
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpcore/_sync/connection_pool.py:236: in handle_request
> >     response = connection.handle_request(
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpcore/_sync/connection.py:103: in handle_request
> >     return self._connection.handle_request(request)
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpcore/_sync/http11.py:136: in handle_request
> >     raise exc
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpcore/_sync/http11.py:106: in handle_request
> >     ) = self._receive_response_headers(**kwargs)
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpcore/_sync/http11.py:177: in _receive_response_headers
> >     event = self._receive_event(timeout=timeout)
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpcore/_sync/http11.py:217: in _receive_event
> >     data = self._network_stream.read(
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpcore/_backends/sync.py:126: in read
> >     with map_exceptions(exc_map):
> > .../hostedtoolcache/Python/3.11.11....../x64/lib/python3.11/contextlib.py:158: in __exit__
> >     self.gen.throw(typ, value, traceback)
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../site-packages/httpcore/_exceptions.py:14: in map_exceptions
> >     raise to_exc(exc) from exc
> > E   httpcore.ReadTimeout: timed out
> > 
> > The above exception was the direct cause of the following exception:
> > .../integrations/httpx/test_httpx.py:305: in test_omit_url_data_if_parsing_fails
> >     response = httpx_client.get(url)
> > .tox/py3.11-httpx-v0.25/lib/python3.11................../site-packages/httpx/_client.py:1041: in get
> >     return self.request(
> > .tox/py3.11-httpx-v0.25/lib/python3.11................../site-packages/httpx/_client.py:814: in request
> >     return self.send(request, auth=auth, follow_redirects=follow_redirects)
> > sentry_sdk/utils.py:1862: in runner
> >     return sentry_patched_function(*args, **kwargs)
> > sentry_sdk/integrations/httpx.py:86: in send
> >     rv = real_send(self, request, **kwargs)
> > .tox/py3.11-httpx-v0.25/lib/python3.11................../site-packages/httpx/_client.py:901: in send
> >     response = self._send_handling_auth(
> > .tox/py3.11-httpx-v0.25/lib/python3.11................../site-packages/httpx/_client.py:929: in _send_handling_auth
> >     response = self._send_handling_redirects(
> > .tox/py3.11-httpx-v0.25/lib/python3.11................../site-packages/httpx/_client.py:966: in _send_handling_redirects
> >     response = self._send_single_request(request)
> > .tox/py3.11-httpx-v0.25/lib/python3.11................../site-packages/httpx/_client.py:1002: in _send_single_request
> >     response = transport.handle_request(request)
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpx/_transports/default.py:227: in handle_request
> >     with map_httpcore_exceptions():
> > .../hostedtoolcache/Python/3.11.11....../x64/lib/python3.11/contextlib.py:158: in __exit__
> >     self.gen.throw(typ, value, traceback)
> > .tox/py3.11-httpx-v0.25/lib/python3.11.../httpx/_transports/default.py:83: in map_httpcore_exceptions
> >     raise mapped_exc(message) from exc
> > E   httpx.ReadTimeout: timed out
> > ```
> 
> </details>

</details>

To view more test analytics, go to the [Test Analytics Dashboard](https://app.codecov.io/gh/getsentry/sentry-python/tests/szokeasaurusrex%2Fmodule_str)
:loudspeaker:  Thoughts on this report? [Let us know!](https://github.com/codecov/feedback/issues/304)

Fix (patch):
diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py
index 7a8917fe..0fead483 100644
--- a/sentry_sdk/utils.py
+++ b/sentry_sdk/utils.py
@@ -1501,7 +1501,7 @@ def qualname_from_function(func):
 
     # Python 3: methods, functions, classes
     if func_qualname is not None:
-        if hasattr(func, "__module__"):
+        if hasattr(func, "__module__") and isinstance(func.__module__, str):
             func_qualname = func.__module__ + "." + func_qualname
         func_qualname = prefix + func_qualname + suffix
 


Test changes:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 6e01bb4f..894638bf 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -951,3 +951,23 @@ def test_format_timestamp_naive():
     # Ensure that some timestamp is returned, without error. We currently treat these as local time, but this is an
     # implementation detail which we should not assert here.
     assert re.fullmatch(timestamp_regex, format_timestamp(datetime_object))
+
+
+def test_qualname_from_function_inner_function():
+    def test_function(): ...
+
+    assert (
+        sentry_sdk.utils.qualname_from_function(test_function)
+        == "tests.test_utils.test_qualname_from_function_inner_function.<locals>.test_function"
+    )
+
+
+def test_qualname_from_function_none_name():
+    def test_function(): ...
+
+    test_function.__module__ = None
+
+    assert (
+        sentry_sdk.utils.qualname_from_function(test_function)
+        == "test_qualname_from_function_none_name.<locals>.test_function"
+    )