Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test-integrations-misc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ jobs:
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-integration_deactivation"
- name: Test shadowed_module
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-shadowed_module"
- name: Generate coverage XML (Python 3.6)
if: ${{ !cancelled() && matrix.python-version == '3.6' }}
run: |
Expand Down
42 changes: 21 additions & 21 deletions scripts/populate_tox/package_dependencies.jsonl

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions scripts/populate_tox/populate_tox.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"cloud_resource_context",
"common",
"integration_deactivation",
"shadowed_module",
"gcp",
"gevent",
"opentelemetry",
Expand Down
28 changes: 14 additions & 14 deletions scripts/populate_tox/releases.jsonl

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions scripts/populate_tox/tox.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ envlist =
# === Integration Deactivation ===
{py3.9,py3.10,py3.11,py3.12,py3.13,py3.14}-integration_deactivation

# === Shadowed Module ===
{py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13,py3.14,py3.14t}-shadowed_module

# === Integrations ===

# Asgi
Expand Down Expand Up @@ -157,10 +160,15 @@ setenv =
django: DJANGO_SETTINGS_MODULE=tests.integrations.django.myapp.settings
spark-v{3.0.3,3.5.6}: JAVA_HOME=/usr/lib/jvm/temurin-11-jdk-amd64

# Avoid polluting test suite with imports
common: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py"
gevent: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py"

# TESTPATH definitions for test suites not managed by toxgen
common: TESTPATH=tests
gevent: TESTPATH=tests
integration_deactivation: TESTPATH=tests/test_ai_integration_deactivation.py
shadowed_module: TESTPATH=tests/test_shadowed_module.py
asgi: TESTPATH=tests/integrations/asgi
aws_lambda: TESTPATH=tests/integrations/aws_lambda
cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context
Expand Down
1 change: 1 addition & 0 deletions scripts/split_tox_gh_actions/split_tox_gh_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
"trytond",
"typer",
"integration_deactivation",
"shadowed_module",
],
}

Expand Down
118 changes: 118 additions & 0 deletions tests/test_shadowed_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import sys
import ast
import types
import pkgutil
import importlib
import pathlib
import pytest

from sentry_sdk import integrations
from sentry_sdk.integrations import _DEFAULT_INTEGRATIONS, Integration


def pytest_generate_tests(metafunc):
"""
All submodules of sentry_sdk.integrations are picked up, so modules
without a subclass of sentry_sdk.integrations.Integration are also tested
for poorly gated imports.

This approach was chosen to keep the implementation simple.
"""
if "integration_submodule_name" in metafunc.fixturenames:
submodule_names = {
submodule_name
for _, submodule_name, _ in pkgutil.walk_packages(integrations.__path__)
}

metafunc.parametrize(
"integration_submodule_name",
# Temporarily skip some integrations
submodule_names
- {
"clickhouse_driver",
"grpc",
"litellm",
"opentelemetry",
"pure_eval",
"ray",
"trytond",
"typer",
Comment on lines +32 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What determines what ends up in the ignore list here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are packages for which the test fails. I've taken a brief look at all of them and there's no false positives.

},
)


def find_unrecognized_dependencies(tree):
"""
Finds unrecognized imports in the AST for a Python module. In an empty
environment the set of non-standard library modules is returned.
"""
unrecognized_dependencies = set()
package_name = lambda name: name.split(".")[0]

for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
root = package_name(alias.name)

try:
if not importlib.util.find_spec(root):
unrecognized_dependencies.add(root)
except ValueError:
continue

elif isinstance(node, ast.ImportFrom):
# if node.level is not 0 the import is relative
if node.level > 0 or node.module is None:
continue

root = package_name(node.module)

try:
if not importlib.util.find_spec(root):
unrecognized_dependencies.add(root)
except ValueError:
continue

return unrecognized_dependencies


@pytest.mark.skipif(
sys.version_info < (3, 7), reason="asyncpg imports __future__.annotations"
)
def test_shadowed_modules_when_importing_integrations(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I understand what's going on here, but it takes some effort to grasp. Maybe a good case for additional comments in the function to better explain what's happening step-by-step?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that's a good call! Added a few comments.

sentry_init, integration_submodule_name
):
"""
Check that importing integrations for third-party module raises an
DidNotEnable exception when the associated module is shadowed by an empty
module.

An integration is determined to be for a third-party module if it cannot
be imported in the environment in which the tests run.
"""
module_path = f"sentry_sdk.integrations.{integration_submodule_name}"
try:
# If importing the integration succeeds in the current environment, assume
# that the integration has no non-standard imports.
importlib.import_module(module_path)
return
except integrations.DidNotEnable:
spec = importlib.util.find_spec(module_path)
source = pathlib.Path(spec.origin).read_text(encoding="utf-8")
tree = ast.parse(source, filename=spec.origin)
integration_dependencies = find_unrecognized_dependencies(tree)

# For each non-standard import, create an empty shadow module to
# emulate an empty "agents.py" or analogous local module that
# shadows the package.
for dependency in integration_dependencies:
sys.modules[dependency] = types.ModuleType(dependency)

# Importing the integration must raise DidNotEnable, since the
# SDK catches the exception type when attempting to activate
# auto-enabling integrations.
with pytest.raises(integrations.DidNotEnable):
importlib.import_module(module_path)

for dependency in integration_dependencies:
del sys.modules[dependency]
68 changes: 38 additions & 30 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ envlist =
# === Integration Deactivation ===
{py3.9,py3.10,py3.11,py3.12,py3.13,py3.14}-integration_deactivation

# === Shadowed Module ===
{py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13,py3.14,py3.14t}-shadowed_module

# === Integrations ===

# Asgi
Expand Down Expand Up @@ -54,14 +57,14 @@ envlist =

# ~~~ MCP ~~~
{py3.10,py3.12,py3.13}-mcp-v1.15.0
{py3.10,py3.12,py3.13}-mcp-v1.17.0
{py3.10,py3.12,py3.13}-mcp-v1.19.0
{py3.10,py3.12,py3.13}-mcp-v1.22.0
{py3.10,py3.12,py3.13}-mcp-v1.18.0
{py3.10,py3.12,py3.13}-mcp-v1.21.2
{py3.10,py3.12,py3.13}-mcp-v1.23.1

{py3.10,py3.13,py3.14,py3.14t}-fastmcp-v0.1.0
{py3.10,py3.13,py3.14,py3.14t}-fastmcp-v0.4.1
{py3.10,py3.13,py3.14,py3.14t}-fastmcp-v1.0
{py3.10,py3.12,py3.13}-fastmcp-v2.13.1
{py3.10,py3.12,py3.13}-fastmcp-v2.13.2


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

{py3.10,py3.12,py3.13}-pydantic_ai-v1.0.18
{py3.10,py3.12,py3.13}-pydantic_ai-v1.8.0
{py3.10,py3.12,py3.13}-pydantic_ai-v1.16.0
{py3.10,py3.12,py3.13}-pydantic_ai-v1.25.1
{py3.10,py3.12,py3.13}-pydantic_ai-v1.9.1
{py3.10,py3.12,py3.13}-pydantic_ai-v1.18.0
{py3.10,py3.12,py3.13}-pydantic_ai-v1.26.0


# ~~~ AI Workflow ~~~
Expand Down Expand Up @@ -107,7 +110,7 @@ envlist =

{py3.8,py3.10,py3.11}-huggingface_hub-v0.24.7
{py3.8,py3.12,py3.13}-huggingface_hub-v0.36.0
{py3.9,py3.13,py3.14,py3.14t}-huggingface_hub-v1.1.6
{py3.9,py3.13,py3.14,py3.14t}-huggingface_hub-v1.1.7

{py3.9,py3.12,py3.13}-litellm-v1.77.7
{py3.9,py3.12,py3.13}-litellm-v1.78.7
Expand All @@ -127,7 +130,7 @@ envlist =
{py3.6,py3.7}-boto3-v1.12.49
{py3.6,py3.9,py3.10}-boto3-v1.21.46
{py3.7,py3.11,py3.12}-boto3-v1.33.13
{py3.9,py3.13,py3.14,py3.14t}-boto3-v1.42.0
{py3.9,py3.13,py3.14,py3.14t}-boto3-v1.42.1

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

{py3.6}-pymongo-v3.5.1
{py3.6,py3.10,py3.11}-pymongo-v3.13.0
{py3.9,py3.13,py3.14,py3.14t}-pymongo-v4.15.4
{py3.9,py3.13,py3.14,py3.14t}-pymongo-v4.15.5

{py3.6}-redis-v2.10.6
{py3.6,py3.7,py3.8}-redis-v3.5.3
Expand Down Expand Up @@ -237,8 +240,8 @@ envlist =
{py3.6,py3.7}-django-v1.11.29
{py3.6,py3.8,py3.9}-django-v2.2.28
{py3.6,py3.9,py3.10}-django-v3.2.25
{py3.8,py3.11,py3.12}-django-v4.2.26
{py3.10,py3.13,py3.14,py3.14t}-django-v5.2.8
{py3.8,py3.11,py3.12}-django-v4.2.27
{py3.10,py3.13,py3.14,py3.14t}-django-v5.2.9
{py3.12,py3.13,py3.14,py3.14t}-django-v6.0rc1

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


# ~~~ Web 2 ~~~
Expand Down Expand Up @@ -377,15 +380,15 @@ deps =

# ~~~ MCP ~~~
mcp-v1.15.0: mcp==1.15.0
mcp-v1.17.0: mcp==1.17.0
mcp-v1.19.0: mcp==1.19.0
mcp-v1.22.0: mcp==1.22.0
mcp-v1.18.0: mcp==1.18.0
mcp-v1.21.2: mcp==1.21.2
mcp-v1.23.1: mcp==1.23.1
mcp: pytest-asyncio

fastmcp-v0.1.0: fastmcp==0.1.0
fastmcp-v0.4.1: fastmcp==0.4.1
fastmcp-v1.0: fastmcp==1.0
fastmcp-v2.13.1: fastmcp==2.13.1
fastmcp-v2.13.2: fastmcp==2.13.2
fastmcp: pytest-asyncio


Expand All @@ -397,9 +400,9 @@ deps =
openai_agents: pytest-asyncio

pydantic_ai-v1.0.18: pydantic-ai==1.0.18
pydantic_ai-v1.8.0: pydantic-ai==1.8.0
pydantic_ai-v1.16.0: pydantic-ai==1.16.0
pydantic_ai-v1.25.1: pydantic-ai==1.25.1
pydantic_ai-v1.9.1: pydantic-ai==1.9.1
pydantic_ai-v1.18.0: pydantic-ai==1.18.0
pydantic_ai-v1.26.0: pydantic-ai==1.26.0
pydantic_ai: pytest-asyncio


Expand Down Expand Up @@ -451,7 +454,7 @@ deps =

huggingface_hub-v0.24.7: huggingface_hub==0.24.7
huggingface_hub-v0.36.0: huggingface_hub==0.36.0
huggingface_hub-v1.1.6: huggingface_hub==1.1.6
huggingface_hub-v1.1.7: huggingface_hub==1.1.7
huggingface_hub: responses
huggingface_hub: pytest-httpx

Expand All @@ -478,7 +481,7 @@ deps =
boto3-v1.12.49: boto3==1.12.49
boto3-v1.21.46: boto3==1.21.46
boto3-v1.33.13: boto3==1.33.13
boto3-v1.42.0: boto3==1.42.0
boto3-v1.42.1: boto3==1.42.1
{py3.7,py3.8}-boto3: urllib3<2.0.0

chalice-v1.16.0: chalice==1.16.0
Expand All @@ -497,7 +500,7 @@ deps =

pymongo-v3.5.1: pymongo==3.5.1
pymongo-v3.13.0: pymongo==3.13.0
pymongo-v4.15.4: pymongo==4.15.4
pymongo-v4.15.5: pymongo==4.15.5
pymongo: mockupdb

redis-v2.10.6: redis==2.10.6
Expand Down Expand Up @@ -630,22 +633,22 @@ deps =
django-v1.11.29: django==1.11.29
django-v2.2.28: django==2.2.28
django-v3.2.25: django==3.2.25
django-v4.2.26: django==4.2.26
django-v5.2.8: django==5.2.8
django-v4.2.27: django==4.2.27
django-v5.2.9: django==5.2.9
django-v6.0rc1: django==6.0rc1
django: psycopg2-binary
django: djangorestframework
django: pytest-django
django: Werkzeug
django-v2.2.28: channels[daphne]
django-v3.2.25: channels[daphne]
django-v4.2.26: channels[daphne]
django-v5.2.8: channels[daphne]
django-v4.2.27: channels[daphne]
django-v5.2.9: channels[daphne]
django-v6.0rc1: channels[daphne]
django-v2.2.28: six
django-v3.2.25: pytest-asyncio
django-v4.2.26: pytest-asyncio
django-v5.2.8: pytest-asyncio
django-v4.2.27: pytest-asyncio
django-v5.2.9: pytest-asyncio
django-v6.0rc1: pytest-asyncio
django-v1.11.29: djangorestframework>=3.0,<4.0
django-v1.11.29: Werkzeug<2.1.0
Expand Down Expand Up @@ -682,7 +685,7 @@ deps =
fastapi-v0.79.1: fastapi==0.79.1
fastapi-v0.94.1: fastapi==0.94.1
fastapi-v0.109.2: fastapi==0.109.2
fastapi-v0.123.0: fastapi==0.123.0
fastapi-v0.123.5: fastapi==0.123.5
fastapi: httpx
fastapi: pytest-asyncio
fastapi: python-multipart
Expand Down Expand Up @@ -800,10 +803,15 @@ setenv =
django: DJANGO_SETTINGS_MODULE=tests.integrations.django.myapp.settings
spark-v{3.0.3,3.5.6}: JAVA_HOME=/usr/lib/jvm/temurin-11-jdk-amd64

# Avoid polluting test suite with imports
common: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py"
gevent: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py"

# TESTPATH definitions for test suites not managed by toxgen
common: TESTPATH=tests
gevent: TESTPATH=tests
integration_deactivation: TESTPATH=tests/test_ai_integration_deactivation.py
shadowed_module: TESTPATH=tests/test_shadowed_module.py
asgi: TESTPATH=tests/integrations/asgi
aws_lambda: TESTPATH=tests/integrations/aws_lambda
cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context
Expand Down