Skip to content

Commit d2d3d35

Browse files
test: Import integrations with empty shadow modules (#5150)
Add a test parametrized on our integrations. The test detects if the integration imports modules not in the standard library, and if so, verifies that importing the integration with an empty shadow module raises a `DidNotEnable` exception. Adding integrations to be auto-enabling can cause SDK crashes in two specific cases we encountered at the end of last week: - the user has an old package version that we don’t support, resulting in an `ImportError` when we patch something that does not yet exist; or - something like an `agents.py` in the environment shadows the import and causes an `ImportError` if the auto-activation still triggers. All integrations with poorly gated imports are not auto-enabling, but some affected integrations are new (e.g., litellm). Closes #5140
1 parent 9a9fbfe commit d2d3d35

File tree

8 files changed

+205
-65
lines changed

8 files changed

+205
-65
lines changed

.github/workflows/test-integrations-misc.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ jobs:
8282
run: |
8383
set -x # print commands that are executed
8484
./scripts/runtox.sh "py${{ matrix.python-version }}-integration_deactivation"
85+
- name: Test shadowed_module
86+
run: |
87+
set -x # print commands that are executed
88+
./scripts/runtox.sh "py${{ matrix.python-version }}-shadowed_module"
8589
- name: Generate coverage XML (Python 3.6)
8690
if: ${{ !cancelled() && matrix.python-version == '3.6' }}
8791
run: |

scripts/populate_tox/package_dependencies.jsonl

Lines changed: 21 additions & 21 deletions
Large diffs are not rendered by default.

scripts/populate_tox/populate_tox.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"cloud_resource_context",
6666
"common",
6767
"integration_deactivation",
68+
"shadowed_module",
6869
"gcp",
6970
"gevent",
7071
"opentelemetry",

scripts/populate_tox/releases.jsonl

Lines changed: 14 additions & 14 deletions
Large diffs are not rendered by default.

scripts/populate_tox/tox.jinja

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ envlist =
2626
# === Integration Deactivation ===
2727
{py3.9,py3.10,py3.11,py3.12,py3.13,py3.14}-integration_deactivation
2828

29+
# === Shadowed Module ===
30+
{py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13,py3.14,py3.14t}-shadowed_module
31+
2932
# === Integrations ===
3033

3134
# Asgi
@@ -157,10 +160,15 @@ setenv =
157160
django: DJANGO_SETTINGS_MODULE=tests.integrations.django.myapp.settings
158161
spark-v{3.0.3,3.5.6}: JAVA_HOME=/usr/lib/jvm/temurin-11-jdk-amd64
159162
163+
# Avoid polluting test suite with imports
164+
common: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py"
165+
gevent: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py"
166+
160167
# TESTPATH definitions for test suites not managed by toxgen
161168
common: TESTPATH=tests
162169
gevent: TESTPATH=tests
163170
integration_deactivation: TESTPATH=tests/test_ai_integration_deactivation.py
171+
shadowed_module: TESTPATH=tests/test_shadowed_module.py
164172
asgi: TESTPATH=tests/integrations/asgi
165173
aws_lambda: TESTPATH=tests/integrations/aws_lambda
166174
cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context

scripts/split_tox_gh_actions/split_tox_gh_actions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@
163163
"trytond",
164164
"typer",
165165
"integration_deactivation",
166+
"shadowed_module",
166167
],
167168
}
168169

tests/test_shadowed_module.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import sys
2+
import ast
3+
import types
4+
import pkgutil
5+
import importlib
6+
import pathlib
7+
import pytest
8+
9+
from sentry_sdk import integrations
10+
from sentry_sdk.integrations import _DEFAULT_INTEGRATIONS, Integration
11+
12+
13+
def pytest_generate_tests(metafunc):
14+
"""
15+
All submodules of sentry_sdk.integrations are picked up, so modules
16+
without a subclass of sentry_sdk.integrations.Integration are also tested
17+
for poorly gated imports.
18+
19+
This approach was chosen to keep the implementation simple.
20+
"""
21+
if "integration_submodule_name" in metafunc.fixturenames:
22+
submodule_names = {
23+
submodule_name
24+
for _, submodule_name, _ in pkgutil.walk_packages(integrations.__path__)
25+
}
26+
27+
metafunc.parametrize(
28+
"integration_submodule_name",
29+
# Temporarily skip some integrations
30+
submodule_names
31+
- {
32+
"clickhouse_driver",
33+
"grpc",
34+
"litellm",
35+
"opentelemetry",
36+
"pure_eval",
37+
"ray",
38+
"trytond",
39+
"typer",
40+
},
41+
)
42+
43+
44+
def find_unrecognized_dependencies(tree):
45+
"""
46+
Finds unrecognized imports in the AST for a Python module. In an empty
47+
environment the set of non-standard library modules is returned.
48+
"""
49+
unrecognized_dependencies = set()
50+
package_name = lambda name: name.split(".")[0]
51+
52+
for node in ast.walk(tree):
53+
if isinstance(node, ast.Import):
54+
for alias in node.names:
55+
root = package_name(alias.name)
56+
57+
try:
58+
if not importlib.util.find_spec(root):
59+
unrecognized_dependencies.add(root)
60+
except ValueError:
61+
continue
62+
63+
elif isinstance(node, ast.ImportFrom):
64+
# if node.level is not 0 the import is relative
65+
if node.level > 0 or node.module is None:
66+
continue
67+
68+
root = package_name(node.module)
69+
70+
try:
71+
if not importlib.util.find_spec(root):
72+
unrecognized_dependencies.add(root)
73+
except ValueError:
74+
continue
75+
76+
return unrecognized_dependencies
77+
78+
79+
@pytest.mark.skipif(
80+
sys.version_info < (3, 7), reason="asyncpg imports __future__.annotations"
81+
)
82+
def test_shadowed_modules_when_importing_integrations(
83+
sentry_init, integration_submodule_name
84+
):
85+
"""
86+
Check that importing integrations for third-party module raises an
87+
DidNotEnable exception when the associated module is shadowed by an empty
88+
module.
89+
90+
An integration is determined to be for a third-party module if it cannot
91+
be imported in the environment in which the tests run.
92+
"""
93+
module_path = f"sentry_sdk.integrations.{integration_submodule_name}"
94+
try:
95+
# If importing the integration succeeds in the current environment, assume
96+
# that the integration has no non-standard imports.
97+
importlib.import_module(module_path)
98+
return
99+
except integrations.DidNotEnable:
100+
spec = importlib.util.find_spec(module_path)
101+
source = pathlib.Path(spec.origin).read_text(encoding="utf-8")
102+
tree = ast.parse(source, filename=spec.origin)
103+
integration_dependencies = find_unrecognized_dependencies(tree)
104+
105+
# For each non-standard import, create an empty shadow module to
106+
# emulate an empty "agents.py" or analogous local module that
107+
# shadows the package.
108+
for dependency in integration_dependencies:
109+
sys.modules[dependency] = types.ModuleType(dependency)
110+
111+
# Importing the integration must raise DidNotEnable, since the
112+
# SDK catches the exception type when attempting to activate
113+
# auto-enabling integrations.
114+
with pytest.raises(integrations.DidNotEnable):
115+
importlib.import_module(module_path)
116+
117+
for dependency in integration_dependencies:
118+
del sys.modules[dependency]

tox.ini

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ envlist =
2626
# === Integration Deactivation ===
2727
{py3.9,py3.10,py3.11,py3.12,py3.13,py3.14}-integration_deactivation
2828

29+
# === Shadowed Module ===
30+
{py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13,py3.14,py3.14t}-shadowed_module
31+
2932
# === Integrations ===
3033

3134
# Asgi
@@ -54,14 +57,14 @@ envlist =
5457

5558
# ~~~ MCP ~~~
5659
{py3.10,py3.12,py3.13}-mcp-v1.15.0
57-
{py3.10,py3.12,py3.13}-mcp-v1.17.0
58-
{py3.10,py3.12,py3.13}-mcp-v1.19.0
59-
{py3.10,py3.12,py3.13}-mcp-v1.22.0
60+
{py3.10,py3.12,py3.13}-mcp-v1.18.0
61+
{py3.10,py3.12,py3.13}-mcp-v1.21.2
62+
{py3.10,py3.12,py3.13}-mcp-v1.23.1
6063

6164
{py3.10,py3.13,py3.14,py3.14t}-fastmcp-v0.1.0
6265
{py3.10,py3.13,py3.14,py3.14t}-fastmcp-v0.4.1
6366
{py3.10,py3.13,py3.14,py3.14t}-fastmcp-v1.0
64-
{py3.10,py3.12,py3.13}-fastmcp-v2.13.1
67+
{py3.10,py3.12,py3.13}-fastmcp-v2.13.2
6568

6669

6770
# ~~~ Agents ~~~
@@ -71,9 +74,9 @@ envlist =
7174
{py3.10,py3.13,py3.14,py3.14t}-openai_agents-v0.6.1
7275

7376
{py3.10,py3.12,py3.13}-pydantic_ai-v1.0.18
74-
{py3.10,py3.12,py3.13}-pydantic_ai-v1.8.0
75-
{py3.10,py3.12,py3.13}-pydantic_ai-v1.16.0
76-
{py3.10,py3.12,py3.13}-pydantic_ai-v1.25.1
77+
{py3.10,py3.12,py3.13}-pydantic_ai-v1.9.1
78+
{py3.10,py3.12,py3.13}-pydantic_ai-v1.18.0
79+
{py3.10,py3.12,py3.13}-pydantic_ai-v1.26.0
7780

7881

7982
# ~~~ AI Workflow ~~~
@@ -107,7 +110,7 @@ envlist =
107110

108111
{py3.8,py3.10,py3.11}-huggingface_hub-v0.24.7
109112
{py3.8,py3.12,py3.13}-huggingface_hub-v0.36.0
110-
{py3.9,py3.13,py3.14,py3.14t}-huggingface_hub-v1.1.6
113+
{py3.9,py3.13,py3.14,py3.14t}-huggingface_hub-v1.1.7
111114

112115
{py3.9,py3.12,py3.13}-litellm-v1.77.7
113116
{py3.9,py3.12,py3.13}-litellm-v1.78.7
@@ -127,7 +130,7 @@ envlist =
127130
{py3.6,py3.7}-boto3-v1.12.49
128131
{py3.6,py3.9,py3.10}-boto3-v1.21.46
129132
{py3.7,py3.11,py3.12}-boto3-v1.33.13
130-
{py3.9,py3.13,py3.14,py3.14t}-boto3-v1.42.0
133+
{py3.9,py3.13,py3.14,py3.14t}-boto3-v1.42.1
131134

132135
{py3.6,py3.7,py3.8}-chalice-v1.16.0
133136
{py3.9,py3.12,py3.13}-chalice-v1.32.0
@@ -143,7 +146,7 @@ envlist =
143146

144147
{py3.6}-pymongo-v3.5.1
145148
{py3.6,py3.10,py3.11}-pymongo-v3.13.0
146-
{py3.9,py3.13,py3.14,py3.14t}-pymongo-v4.15.4
149+
{py3.9,py3.13,py3.14,py3.14t}-pymongo-v4.15.5
147150

148151
{py3.6}-redis-v2.10.6
149152
{py3.6,py3.7,py3.8}-redis-v3.5.3
@@ -237,8 +240,8 @@ envlist =
237240
{py3.6,py3.7}-django-v1.11.29
238241
{py3.6,py3.8,py3.9}-django-v2.2.28
239242
{py3.6,py3.9,py3.10}-django-v3.2.25
240-
{py3.8,py3.11,py3.12}-django-v4.2.26
241-
{py3.10,py3.13,py3.14,py3.14t}-django-v5.2.8
243+
{py3.8,py3.11,py3.12}-django-v4.2.27
244+
{py3.10,py3.13,py3.14,py3.14t}-django-v5.2.9
242245
{py3.12,py3.13,py3.14,py3.14t}-django-v6.0rc1
243246

244247
{py3.6,py3.7,py3.8}-flask-v1.1.4
@@ -253,7 +256,7 @@ envlist =
253256
{py3.6,py3.9,py3.10}-fastapi-v0.79.1
254257
{py3.7,py3.10,py3.11}-fastapi-v0.94.1
255258
{py3.8,py3.11,py3.12}-fastapi-v0.109.2
256-
{py3.8,py3.13,py3.14,py3.14t}-fastapi-v0.123.0
259+
{py3.8,py3.13,py3.14,py3.14t}-fastapi-v0.123.5
257260

258261

259262
# ~~~ Web 2 ~~~
@@ -377,15 +380,15 @@ deps =
377380

378381
# ~~~ MCP ~~~
379382
mcp-v1.15.0: mcp==1.15.0
380-
mcp-v1.17.0: mcp==1.17.0
381-
mcp-v1.19.0: mcp==1.19.0
382-
mcp-v1.22.0: mcp==1.22.0
383+
mcp-v1.18.0: mcp==1.18.0
384+
mcp-v1.21.2: mcp==1.21.2
385+
mcp-v1.23.1: mcp==1.23.1
383386
mcp: pytest-asyncio
384387

385388
fastmcp-v0.1.0: fastmcp==0.1.0
386389
fastmcp-v0.4.1: fastmcp==0.4.1
387390
fastmcp-v1.0: fastmcp==1.0
388-
fastmcp-v2.13.1: fastmcp==2.13.1
391+
fastmcp-v2.13.2: fastmcp==2.13.2
389392
fastmcp: pytest-asyncio
390393

391394

@@ -397,9 +400,9 @@ deps =
397400
openai_agents: pytest-asyncio
398401

399402
pydantic_ai-v1.0.18: pydantic-ai==1.0.18
400-
pydantic_ai-v1.8.0: pydantic-ai==1.8.0
401-
pydantic_ai-v1.16.0: pydantic-ai==1.16.0
402-
pydantic_ai-v1.25.1: pydantic-ai==1.25.1
403+
pydantic_ai-v1.9.1: pydantic-ai==1.9.1
404+
pydantic_ai-v1.18.0: pydantic-ai==1.18.0
405+
pydantic_ai-v1.26.0: pydantic-ai==1.26.0
403406
pydantic_ai: pytest-asyncio
404407

405408

@@ -451,7 +454,7 @@ deps =
451454

452455
huggingface_hub-v0.24.7: huggingface_hub==0.24.7
453456
huggingface_hub-v0.36.0: huggingface_hub==0.36.0
454-
huggingface_hub-v1.1.6: huggingface_hub==1.1.6
457+
huggingface_hub-v1.1.7: huggingface_hub==1.1.7
455458
huggingface_hub: responses
456459
huggingface_hub: pytest-httpx
457460

@@ -478,7 +481,7 @@ deps =
478481
boto3-v1.12.49: boto3==1.12.49
479482
boto3-v1.21.46: boto3==1.21.46
480483
boto3-v1.33.13: boto3==1.33.13
481-
boto3-v1.42.0: boto3==1.42.0
484+
boto3-v1.42.1: boto3==1.42.1
482485
{py3.7,py3.8}-boto3: urllib3<2.0.0
483486

484487
chalice-v1.16.0: chalice==1.16.0
@@ -497,7 +500,7 @@ deps =
497500

498501
pymongo-v3.5.1: pymongo==3.5.1
499502
pymongo-v3.13.0: pymongo==3.13.0
500-
pymongo-v4.15.4: pymongo==4.15.4
503+
pymongo-v4.15.5: pymongo==4.15.5
501504
pymongo: mockupdb
502505

503506
redis-v2.10.6: redis==2.10.6
@@ -630,22 +633,22 @@ deps =
630633
django-v1.11.29: django==1.11.29
631634
django-v2.2.28: django==2.2.28
632635
django-v3.2.25: django==3.2.25
633-
django-v4.2.26: django==4.2.26
634-
django-v5.2.8: django==5.2.8
636+
django-v4.2.27: django==4.2.27
637+
django-v5.2.9: django==5.2.9
635638
django-v6.0rc1: django==6.0rc1
636639
django: psycopg2-binary
637640
django: djangorestframework
638641
django: pytest-django
639642
django: Werkzeug
640643
django-v2.2.28: channels[daphne]
641644
django-v3.2.25: channels[daphne]
642-
django-v4.2.26: channels[daphne]
643-
django-v5.2.8: channels[daphne]
645+
django-v4.2.27: channels[daphne]
646+
django-v5.2.9: channels[daphne]
644647
django-v6.0rc1: channels[daphne]
645648
django-v2.2.28: six
646649
django-v3.2.25: pytest-asyncio
647-
django-v4.2.26: pytest-asyncio
648-
django-v5.2.8: pytest-asyncio
650+
django-v4.2.27: pytest-asyncio
651+
django-v5.2.9: pytest-asyncio
649652
django-v6.0rc1: pytest-asyncio
650653
django-v1.11.29: djangorestframework>=3.0,<4.0
651654
django-v1.11.29: Werkzeug<2.1.0
@@ -682,7 +685,7 @@ deps =
682685
fastapi-v0.79.1: fastapi==0.79.1
683686
fastapi-v0.94.1: fastapi==0.94.1
684687
fastapi-v0.109.2: fastapi==0.109.2
685-
fastapi-v0.123.0: fastapi==0.123.0
688+
fastapi-v0.123.5: fastapi==0.123.5
686689
fastapi: httpx
687690
fastapi: pytest-asyncio
688691
fastapi: python-multipart
@@ -800,10 +803,15 @@ setenv =
800803
django: DJANGO_SETTINGS_MODULE=tests.integrations.django.myapp.settings
801804
spark-v{3.0.3,3.5.6}: JAVA_HOME=/usr/lib/jvm/temurin-11-jdk-amd64
802805

806+
# Avoid polluting test suite with imports
807+
common: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py"
808+
gevent: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py"
809+
803810
# TESTPATH definitions for test suites not managed by toxgen
804811
common: TESTPATH=tests
805812
gevent: TESTPATH=tests
806813
integration_deactivation: TESTPATH=tests/test_ai_integration_deactivation.py
814+
shadowed_module: TESTPATH=tests/test_shadowed_module.py
807815
asgi: TESTPATH=tests/integrations/asgi
808816
aws_lambda: TESTPATH=tests/integrations/aws_lambda
809817
cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context

0 commit comments

Comments
 (0)