diff --git a/.github/workflows/test-integration-starlite.yml b/.github/workflows/test-integration-starlite.yml new file mode 100644 index 0000000000..8a40f7d48c --- /dev/null +++ b/.github/workflows/test-integration-starlite.yml @@ -0,0 +1,73 @@ +name: Test starlite + +on: + push: + branches: + - master + - release/** + + pull_request: + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless + +jobs: + test: + name: starlite, python ${{ matrix.python-version }}, ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + + strategy: + fail-fast: false + matrix: + python-version: ["3.8","3.9","3.10","3.11"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-20.04] + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Setup Test Env + run: | + pip install codecov "tox>=3,<4" + + - name: Test starlite + timeout-minutes: 45 + shell: bash + run: | + set -x # print commands that are executed + coverage erase + + ./scripts/runtox.sh "${{ matrix.python-version }}-starlite" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch + coverage combine .coverage* + coverage xml -i + codecov --file coverage.xml + + check_required_tests: + name: All starlite tests passed or skipped + needs: test + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-20.04 + steps: + - name: Check for failures + if: contains(needs.test.result, 'failure') + run: | + echo "One of the dependent jobs have failed. You may need to re-run it." && exit 1 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000000..d316e6d5f1 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.7.12 diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 00b2994ce1..2087202bad 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -63,6 +63,9 @@ class OP: MIDDLEWARE_STARLETTE = "middleware.starlette" MIDDLEWARE_STARLETTE_RECEIVE = "middleware.starlette.receive" MIDDLEWARE_STARLETTE_SEND = "middleware.starlette.send" + MIDDLEWARE_STARLITE = "middleware.starlite" + MIDDLEWARE_STARLITE_RECEIVE = "middleware.starlite.receive" + MIDDLEWARE_STARLITE_SEND = "middleware.starlite.send" QUEUE_SUBMIT_CELERY = "queue.submit.celery" QUEUE_TASK_CELERY = "queue.task.celery" QUEUE_TASK_RQ = "queue.task.rq" diff --git a/sentry_sdk/integrations/starlite.py b/sentry_sdk/integrations/starlite.py new file mode 100644 index 0000000000..2a5a6150bb --- /dev/null +++ b/sentry_sdk/integrations/starlite.py @@ -0,0 +1,271 @@ +from typing import TYPE_CHECKING + +from pydantic import BaseModel # type: ignore +from sentry_sdk.consts import OP +from sentry_sdk.hub import Hub, _should_send_default_pii +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_ROUTE +from sentry_sdk.utils import event_from_exception, transaction_from_function + +try: + from starlite import Request, Starlite, State # type: ignore + from starlite.handlers.base import BaseRouteHandler # type: ignore + from starlite.middleware import DefineMiddleware # type: ignore + from starlite.plugins.base import get_plugin_for_value # type: ignore + from starlite.routes.http import HTTPRoute # type: ignore + from starlite.utils import ConnectionDataExtractor, is_async_callable, Ref # type: ignore + + if TYPE_CHECKING: + from typing import Any, Dict, List, Optional, Union + from starlite.types import ( # type: ignore + ASGIApp, + HTTPReceiveMessage, + HTTPScope, + Message, + Middleware, + Receive, + Scope, + Send, + WebSocketReceiveMessage, + ) + from starlite import MiddlewareProtocol + from sentry_sdk._types import Event +except ImportError: + raise DidNotEnable("Starlite is not installed") + + +_DEFAULT_TRANSACTION_NAME = "generic Starlite request" + + +class SentryStarliteASGIMiddleware(SentryAsgiMiddleware): + def __init__(self, app: "ASGIApp"): + super().__init__( + app=app, + unsafe_context_data=False, + transaction_style="endpoint", + mechanism_type="asgi", + ) + + +class StarliteIntegration(Integration): + identifier = "starlite" + + @staticmethod + def setup_once() -> None: + patch_app_init() + patch_middlewares() + patch_http_route_handle() + + +def patch_app_init() -> None: + """ + Replaces the Starlite class's `__init__` function in order to inject `after_exception` handlers and set the + `SentryStarliteASGIMiddleware` as the outmost middleware in the stack. + See: + - https://starlite-api.github.io/starlite/usage/0-the-starlite-app/5-application-hooks/#after-exception + - https://starlite-api.github.io/starlite/usage/7-middleware/0-middleware-intro/ + """ + old__init__ = Starlite.__init__ + + def injection_wrapper(self: "Starlite", *args: "Any", **kwargs: "Any") -> None: + + after_exception = kwargs.pop("after_exception", []) + kwargs.update( + after_exception=[ + exception_handler, + *( + after_exception + if isinstance(after_exception, list) + else [after_exception] + ), + ] + ) + + SentryStarliteASGIMiddleware.__call__ = SentryStarliteASGIMiddleware._run_asgi3 + middleware = kwargs.pop("middleware", None) or [] + kwargs["middleware"] = [SentryStarliteASGIMiddleware, *middleware] + old__init__(self, *args, **kwargs) + + Starlite.__init__ = injection_wrapper + + +def patch_middlewares() -> None: + old__resolve_middleware_stack = BaseRouteHandler.resolve_middleware + + def resolve_middleware_wrapper(self: "Any") -> "List[Middleware]": + return [ + enable_span_for_middleware(middleware) + for middleware in old__resolve_middleware_stack(self) + ] + + BaseRouteHandler.resolve_middleware = resolve_middleware_wrapper + + +def enable_span_for_middleware(middleware: "Middleware") -> "Middleware": + if ( + not hasattr(middleware, "__call__") # noqa: B004 + or middleware is SentryStarliteASGIMiddleware + ): + return middleware + + if isinstance(middleware, DefineMiddleware): + old_call: "ASGIApp" = middleware.middleware.__call__ + else: + old_call = middleware.__call__ + + async def _create_span_call( + self: "MiddlewareProtocol", scope: "Scope", receive: "Receive", send: "Send" + ) -> None: + hub = Hub.current + integration = hub.get_integration(StarliteIntegration) + if integration is not None: + middleware_name = self.__class__.__name__ + with hub.start_span( + op=OP.MIDDLEWARE_STARLITE, description=middleware_name + ) as middleware_span: + middleware_span.set_tag("starlite.middleware_name", middleware_name) + + # Creating spans for the "receive" callback + async def _sentry_receive( + *args: "Any", **kwargs: "Any" + ) -> "Union[HTTPReceiveMessage, WebSocketReceiveMessage]": + hub = Hub.current + with hub.start_span( + op=OP.MIDDLEWARE_STARLITE_RECEIVE, + description=getattr(receive, "__qualname__", str(receive)), + ) as span: + span.set_tag("starlite.middleware_name", middleware_name) + return await receive(*args, **kwargs) + + receive_name = getattr(receive, "__name__", str(receive)) + receive_patched = receive_name == "_sentry_receive" + new_receive = _sentry_receive if not receive_patched else receive + + # Creating spans for the "send" callback + async def _sentry_send(message: "Message") -> None: + hub = Hub.current + with hub.start_span( + op=OP.MIDDLEWARE_STARLITE_SEND, + description=getattr(send, "__qualname__", str(send)), + ) as span: + span.set_tag("starlite.middleware_name", middleware_name) + return await send(message) + + send_name = getattr(send, "__name__", str(send)) + send_patched = send_name == "_sentry_send" + new_send = _sentry_send if not send_patched else send + + return await old_call(self, scope, new_receive, new_send) + else: + return await old_call(self, scope, receive, send) + + not_yet_patched = old_call.__name__ not in ["_create_span_call"] + + if not_yet_patched: + if isinstance(middleware, DefineMiddleware): + middleware.middleware.__call__ = _create_span_call + else: + middleware.__call__ = _create_span_call + + return middleware + + +def patch_http_route_handle() -> None: + old_handle = HTTPRoute.handle + + async def handle_wrapper( + self: "HTTPRoute", scope: "HTTPScope", receive: "Receive", send: "Send" + ) -> None: + hub = Hub.current + integration: StarliteIntegration = hub.get_integration(StarliteIntegration) + if integration is None: + return await old_handle(self, scope, receive, send) + + with hub.configure_scope() as sentry_scope: + request: "Request[Any, Any]" = scope["app"].request_class( + scope=scope, receive=receive, send=send + ) + extracted_request_data = ConnectionDataExtractor( + parse_body=True, parse_query=True + )(request) + body = extracted_request_data.pop("body") + + request_data = await body + + def event_processor(event: "Event", _: "Dict[str, Any]") -> "Event": + route_handler = scope.get("route_handler") + + request_info = event.get("request", {}) + request_info["content_length"] = len(scope.get("_body", b"")) + if _should_send_default_pii(): + request_info["cookies"] = extracted_request_data["cookies"] + if request_data is not None: + request_info["data"] = request_data + + func = None + if route_handler.name is not None: + tx_name = route_handler.name + elif isinstance(route_handler.fn, Ref): + func = route_handler.fn.value + else: + func = route_handler.fn + if func is not None: + tx_name = transaction_from_function(func) + + tx_info = {"source": SOURCE_FOR_STYLE["endpoint"]} + + if not tx_name: + tx_name = _DEFAULT_TRANSACTION_NAME + tx_info = {"source": TRANSACTION_SOURCE_ROUTE} + + event.update( + request=request_info, transaction=tx_name, transaction_info=tx_info + ) + return event + + sentry_scope._name = StarliteIntegration.identifier + sentry_scope.add_event_processor(event_processor) + + return await old_handle(self, scope, receive, send) + + HTTPRoute.handle = handle_wrapper + + +def retrieve_user_from_scope(scope: "Scope") -> "Optional[Dict[str, Any]]": + scope_user = scope.get("user", {}) + if not scope_user: + return None + if isinstance(scope_user, dict): + return scope_user + if isinstance(scope_user, BaseModel): + return scope_user.dict() + if hasattr(scope_user, "asdict"): # dataclasses + return scope_user.asdict() + + plugin = get_plugin_for_value(scope_user) + if plugin and not is_async_callable(plugin.to_dict): + return plugin.to_dict(scope_user) + + return None + + +def exception_handler(exc: Exception, scope: "Scope", _: "State") -> None: + hub = Hub.current + if hub.get_integration(StarliteIntegration) is None: + return + + user_info: "Optional[Dict[str, Any]]" = None + if _should_send_default_pii(): + user_info = retrieve_user_from_scope(scope) + if user_info and isinstance(user_info, dict): + with hub.configure_scope() as sentry_scope: + sentry_scope.set_user(user_info) + + event, hint = event_from_exception( + exc, + client_options=hub.client.options if hub.client else None, + mechanism={"type": StarliteIntegration.identifier, "handled": False}, + ) + + hub.capture_event(event, hint=hint) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index c000a3bd2c..4d6a091398 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -3,35 +3,42 @@ import linecache import logging import os +import re +import subprocess import sys import threading -import subprocess -import re import time - from datetime import datetime +from functools import partial -import sentry_sdk -from sentry_sdk._compat import urlparse, text_type, implements_str, PY2, PY33, PY37 +try: + from functools import partialmethod + _PARTIALMETHOD_AVAILABLE = True +except ImportError: + _PARTIALMETHOD_AVAILABLE = False + +import sentry_sdk +from sentry_sdk._compat import PY2, PY33, PY37, implements_str, text_type, urlparse from sentry_sdk._types import MYPY if MYPY: - from types import FrameType - from types import TracebackType - from typing import Any - from typing import Callable - from typing import Dict - from typing import ContextManager - from typing import Iterator - from typing import List - from typing import Optional - from typing import Set - from typing import Tuple - from typing import Union - from typing import Type - - from sentry_sdk._types import ExcInfo, EndpointType + from types import FrameType, TracebackType + from typing import ( + Any, + Callable, + ContextManager, + Dict, + Iterator, + List, + Optional, + Set, + Tuple, + Type, + Union, + ) + + from sentry_sdk._types import EndpointType, ExcInfo epoch = datetime(1970, 1, 1) @@ -968,9 +975,12 @@ def _get_contextvars(): """ -def transaction_from_function(func): +def qualname_from_function(func): # type: (Callable[..., Any]) -> Optional[str] - # Methods in Python 2 + """Return the qualified name of func. Works with regular function, lambda, partial and partialmethod.""" + func_qualname = None # type: Optional[str] + + # Python 2 try: return "%s.%s.%s" % ( func.im_class.__module__, # type: ignore @@ -980,26 +990,38 @@ def transaction_from_function(func): except Exception: pass - func_qualname = ( - getattr(func, "__qualname__", None) or getattr(func, "__name__", None) or None - ) # type: Optional[str] - - if not func_qualname: - # No idea what it is - return None + prefix, suffix = "", "" - # Methods in Python 3 - # Functions - # Classes - try: - return "%s.%s" % (func.__module__, func_qualname) - except Exception: - pass + if ( + _PARTIALMETHOD_AVAILABLE + and hasattr(func, "_partialmethod") + and isinstance(func._partialmethod, partialmethod) # type: ignore + ): + prefix, suffix = "partialmethod()" + func = func._partialmethod.func # type: ignore + elif isinstance(func, partial) and hasattr(func.func, "__name__"): + prefix, suffix = "partial()" + func = func.func + + if hasattr(func, "__qualname__"): + func_qualname = func.__qualname__ + elif hasattr(func, "__name__"): # Python 2.7 has no __qualname__ + func_qualname = func.__name__ + + # Python 3: methods, functions, classes + if func_qualname is not None: + if hasattr(func, "__module__"): + func_qualname = func.__module__ + "." + func_qualname + func_qualname = prefix + func_qualname + suffix - # Possibly a lambda return func_qualname +def transaction_from_function(func): + # type: (Callable[..., Any]) -> Optional[str] + return qualname_from_function(func) + + disable_capture_event = ContextVar("disable_capture_event") diff --git a/setup.py b/setup.py index 86680690ce..3a52ba1961 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,7 @@ def get_file_text(file_name): "chalice": ["chalice>=1.16.0"], "httpx": ["httpx>=0.16.0"], "starlette": ["starlette>=0.19.1"], + "starlite": ["starlite>=1.48"], "fastapi": ["fastapi>=0.79.0"], "pymongo": ["pymongo>=3.1"], "opentelemetry": ["opentelemetry-distro>=0.350b0"], diff --git a/tests/integrations/starlite/__init__.py b/tests/integrations/starlite/__init__.py new file mode 100644 index 0000000000..4c1037671d --- /dev/null +++ b/tests/integrations/starlite/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("starlite") diff --git a/tests/integrations/starlite/test_starlite.py b/tests/integrations/starlite/test_starlite.py new file mode 100644 index 0000000000..603697ce8b --- /dev/null +++ b/tests/integrations/starlite/test_starlite.py @@ -0,0 +1,325 @@ +import functools + +import pytest + +from sentry_sdk import capture_exception, capture_message, last_event_id +from sentry_sdk.integrations.starlite import StarliteIntegration + +starlite = pytest.importorskip("starlite") + +from typing import Any, Dict + +from starlite import AbstractMiddleware, LoggingConfig, Starlite, get, Controller +from starlite.middleware import LoggingMiddlewareConfig, RateLimitConfig +from starlite.middleware.session.memory_backend import MemoryBackendConfig +from starlite.status_codes import HTTP_500_INTERNAL_SERVER_ERROR +from starlite.testing import TestClient + + +class SampleMiddleware(AbstractMiddleware): + async def __call__(self, scope, receive, send) -> None: + async def do_stuff(message): + if message["type"] == "http.response.start": + # do something here. + pass + await send(message) + + await self.app(scope, receive, do_stuff) + + +class SampleReceiveSendMiddleware(AbstractMiddleware): + async def __call__(self, scope, receive, send): + message = await receive() + assert message + assert message["type"] == "http.request" + + send_output = await send({"type": "something-unimportant"}) + assert send_output is None + + await self.app(scope, receive, send) + + +class SamplePartialReceiveSendMiddleware(AbstractMiddleware): + async def __call__(self, scope, receive, send): + message = await receive() + assert message + assert message["type"] == "http.request" + + send_output = await send({"type": "something-unimportant"}) + assert send_output is None + + async def my_receive(*args, **kwargs): + pass + + async def my_send(*args, **kwargs): + pass + + partial_receive = functools.partial(my_receive) + partial_send = functools.partial(my_send) + + await self.app(scope, partial_receive, partial_send) + + +def starlite_app_factory(middleware=None, debug=True, exception_handlers=None): + class MyController(Controller): + path = "/controller" + + @get("/error") + async def controller_error(self) -> None: + raise Exception("Whoa") + + @get("/some_url") + async def homepage_handler() -> Dict[str, Any]: + 1 / 0 + return {"status": "ok"} + + @get("/custom_error", name="custom_name") + async def custom_error() -> Any: + raise Exception("Too Hot") + + @get("/message") + async def message() -> Dict[str, Any]: + capture_message("hi") + return {"status": "ok"} + + @get("/message/{message_id:str}") + async def message_with_id() -> Dict[str, Any]: + capture_message("hi") + return {"status": "ok"} + + logging_config = LoggingConfig() + + app = Starlite( + route_handlers=[ + homepage_handler, + custom_error, + message, + message_with_id, + MyController, + ], + debug=debug, + middleware=middleware, + logging_config=logging_config, + exception_handlers=exception_handlers, + ) + + return app + + +@pytest.mark.parametrize( + "test_url,expected_error,expected_message,expected_tx_name", + [ + ( + "/some_url", + ZeroDivisionError, + "division by zero", + "tests.integrations.starlite.test_starlite.starlite_app_factory..homepage_handler", + ), + ( + "/custom_error", + Exception, + "Too Hot", + "custom_name", + ), + ( + "/controller/error", + Exception, + "Whoa", + "partial(.MyController.controller_error>)", + ), + ], +) +def test_catch_exceptions( + sentry_init, + capture_exceptions, + capture_events, + test_url, + expected_error, + expected_message, + expected_tx_name, +): + sentry_init(integrations=[StarliteIntegration()]) + starlite_app = starlite_app_factory() + exceptions = capture_exceptions() + events = capture_events() + + client = TestClient(starlite_app) + try: + client.get(test_url) + except Exception: + pass + + (exc,) = exceptions + assert isinstance(exc, expected_error) + assert str(exc) == expected_message + + (event,) = events + assert event["exception"]["values"][0]["mechanism"]["type"] == "starlite" + assert event["transaction"] == expected_tx_name + + +def test_middleware_spans(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarliteIntegration()], + ) + + logging_config = LoggingMiddlewareConfig() + session_config = MemoryBackendConfig() + rate_limit_config = RateLimitConfig(rate_limit=("hour", 5)) + + starlite_app = starlite_app_factory( + middleware=[ + session_config.middleware, + logging_config.middleware, + rate_limit_config.middleware, + ] + ) + events = capture_events() + + client = TestClient( + starlite_app, raise_server_exceptions=False, base_url="http://testserver.local" + ) + try: + client.get("/message") + except Exception: + pass + + (_, transaction_event) = events + + expected = ["SessionMiddleware", "LoggingMiddleware", "RateLimitMiddleware"] + + idx = 0 + for span in transaction_event["spans"]: + if span["op"] == "middleware.starlite": + assert span["description"] == expected[idx] + assert span["tags"]["starlite.middleware_name"] == expected[idx] + idx += 1 + + +def test_middleware_callback_spans(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarliteIntegration()], + ) + starlette_app = starlite_app_factory(middleware=[SampleMiddleware]) + events = capture_events() + + client = TestClient(starlette_app, raise_server_exceptions=False) + try: + client.get("/message") + except Exception: + pass + + (_, transaction_event) = events + + expected = [ + { + "op": "middleware.starlite", + "description": "SampleMiddleware", + "tags": {"starlite.middleware_name": "SampleMiddleware"}, + }, + { + "op": "middleware.starlite.send", + "description": "TestClientTransport.create_send..send", + "tags": {"starlite.middleware_name": "SampleMiddleware"}, + }, + { + "op": "middleware.starlite.send", + "description": "TestClientTransport.create_send..send", + "tags": {"starlite.middleware_name": "SampleMiddleware"}, + }, + ] + print(transaction_event["spans"]) + idx = 0 + for span in transaction_event["spans"]: + assert span["op"] == expected[idx]["op"] + assert span["description"] == expected[idx]["description"] + assert span["tags"] == expected[idx]["tags"] + idx += 1 + + +def test_middleware_receive_send(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarliteIntegration()], + ) + starlette_app = starlite_app_factory(middleware=[SampleReceiveSendMiddleware]) + + client = TestClient(starlette_app, raise_server_exceptions=False) + try: + # NOTE: the assert statements checking + # for correct behaviour are in `SampleReceiveSendMiddleware`! + client.get("/message") + except Exception: + pass + + +def test_middleware_partial_receive_send(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarliteIntegration()], + ) + starlette_app = starlite_app_factory( + middleware=[SamplePartialReceiveSendMiddleware] + ) + events = capture_events() + + client = TestClient(starlette_app, raise_server_exceptions=False) + try: + client.get("/message") + except Exception: + pass + + (_, transaction_event) = events + + expected = [ + { + "op": "middleware.starlite", + "description": "SamplePartialReceiveSendMiddleware", + "tags": {"starlite.middleware_name": "SamplePartialReceiveSendMiddleware"}, + }, + { + "op": "middleware.starlite.receive", + "description": "TestClientTransport.create_receive..receive", + "tags": {"starlite.middleware_name": "SamplePartialReceiveSendMiddleware"}, + }, + { + "op": "middleware.starlite.send", + "description": "TestClientTransport.create_send..send", + "tags": {"starlite.middleware_name": "SamplePartialReceiveSendMiddleware"}, + }, + ] + + print(transaction_event["spans"]) + idx = 0 + for span in transaction_event["spans"]: + assert span["op"] == expected[idx]["op"] + assert span["description"].startswith(expected[idx]["description"]) + assert span["tags"] == expected[idx]["tags"] + idx += 1 + + +def test_last_event_id(sentry_init, capture_events): + sentry_init( + integrations=[StarliteIntegration()], + ) + events = capture_events() + + def handler(request, exc): + capture_exception(exc) + return starlite.response.Response(last_event_id(), status_code=500) + + app = starlite_app_factory( + debug=False, exception_handlers={HTTP_500_INTERNAL_SERVER_ERROR: handler} + ) + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/custom_error") + assert response.status_code == 500 + print(events) + event = events[-1] + assert response.content.strip().decode("ascii").strip('"') == event["event_id"] + (exception,) = event["exception"]["values"] + assert exception["type"] == "Exception" + assert exception["value"] == "Too Hot" diff --git a/tests/utils/test_transaction.py b/tests/utils/test_transaction.py index e1aa12308f..bfb87f4c29 100644 --- a/tests/utils/test_transaction.py +++ b/tests/utils/test_transaction.py @@ -1,5 +1,15 @@ +import sys +from functools import partial + +import pytest + from sentry_sdk.utils import transaction_from_function +try: + from functools import partialmethod +except ImportError: + pass + class MyClass: def myfunc(self): @@ -10,6 +20,16 @@ def myfunc(): pass +@partial +def my_partial(): + pass + + +my_lambda = lambda: None + +my_partial_lambda = partial(lambda: None) + + def test_transaction_from_function(): x = transaction_from_function assert x(MyClass) == "tests.utils.test_transaction.MyClass" @@ -18,3 +38,26 @@ def test_transaction_from_function(): assert x(None) is None assert x(42) is None assert x(lambda: None).endswith("") + assert x(my_lambda) == "tests.utils.test_transaction." + assert ( + x(my_partial) == "partial()" + ) + assert ( + x(my_partial_lambda) + == "partial(>)" + ) + + +@pytest.mark.skipif(sys.version_info < (3, 4), reason="Require python 3.4 or higher") +def test_transaction_from_function_partialmethod(): + x = transaction_from_function + + class MyPartialClass: + @partialmethod + def my_partial_method(self): + pass + + assert ( + x(MyPartialClass.my_partial_method) + == "partialmethod(.MyPartialClass.my_partial_method>)" + ) diff --git a/tox.ini b/tox.ini index 50a1a7b3ec..a64e2d4987 100644 --- a/tox.ini +++ b/tox.ini @@ -122,6 +122,9 @@ envlist = # Starlette {py3.7,py3.8,py3.9,py3.10,py3.11}-starlette-v{0.19.1,0.20,0.21} + # Starlite + {py3.8,py3.9,py3.10,py3.11}-starlite + # SQL Alchemy {py2.7,py3.7,py3.8,py3.9,py3.10,py3.11}-sqlalchemy-v{1.2,1.3} @@ -340,6 +343,13 @@ deps = starlette-v0.20: starlette>=0.20.0,<0.21.0 starlette-v0.21: starlette>=0.21.0,<0.22.0 + # Starlite + starlite: starlite + starlite: pytest-asyncio + starlite: python-multipart + starlite: requests + starlite: cryptography + # SQLAlchemy sqlalchemy-v1.2: sqlalchemy>=1.2,<1.3 sqlalchemy-v1.3: sqlalchemy>=1.3,<1.4 @@ -384,6 +394,7 @@ setenv = rq: TESTPATH=tests/integrations/rq sanic: TESTPATH=tests/integrations/sanic starlette: TESTPATH=tests/integrations/starlette + starlite: TESTPATH=tests/integrations/starlite sqlalchemy: TESTPATH=tests/integrations/sqlalchemy tornado: TESTPATH=tests/integrations/tornado trytond: TESTPATH=tests/integrations/trytond