diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 0c66baa03a..14cbce82d6 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -68,7 +68,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -130,7 +130,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-aws-lambda.yml b/.github/workflows/test-integrations-aws-lambda.yml index 922a39fc16..1a463d57eb 100644 --- a/.github/workflows/test-integrations-aws-lambda.yml +++ b/.github/workflows/test-integrations-aws-lambda.yml @@ -87,7 +87,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-cloud-computing.yml b/.github/workflows/test-integrations-cloud-computing.yml index 48fdfc9b7d..c36c769b76 100644 --- a/.github/workflows/test-integrations-cloud-computing.yml +++ b/.github/workflows/test-integrations-cloud-computing.yml @@ -64,7 +64,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -122,7 +122,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index ec554ebb07..048341b2e4 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -52,7 +52,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-data-processing.yml b/.github/workflows/test-integrations-data-processing.yml index d809d089f3..91483821f2 100644 --- a/.github/workflows/test-integrations-data-processing.yml +++ b/.github/workflows/test-integrations-data-processing.yml @@ -82,7 +82,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -158,7 +158,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-databases.yml b/.github/workflows/test-integrations-databases.yml index 5bca73c83e..08024e766f 100644 --- a/.github/workflows/test-integrations-databases.yml +++ b/.github/workflows/test-integrations-databases.yml @@ -91,7 +91,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -176,7 +176,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index 3b4aa9f1b8..9a6c89eca0 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -64,7 +64,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -122,7 +122,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-miscellaneous.yml b/.github/workflows/test-integrations-miscellaneous.yml index 143648fba3..14af8099f7 100644 --- a/.github/workflows/test-integrations-miscellaneous.yml +++ b/.github/workflows/test-integrations-miscellaneous.yml @@ -68,7 +68,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -130,7 +130,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-networking.yml b/.github/workflows/test-integrations-networking.yml index 124bbc065a..166d6e43eb 100644 --- a/.github/workflows/test-integrations-networking.yml +++ b/.github/workflows/test-integrations-networking.yml @@ -64,7 +64,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -122,7 +122,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-frameworks-1.yml b/.github/workflows/test-integrations-web-frameworks-1.yml index 651118ab24..4092bc9cff 100644 --- a/.github/workflows/test-integrations-web-frameworks-1.yml +++ b/.github/workflows/test-integrations-web-frameworks-1.yml @@ -82,7 +82,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -158,7 +158,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-frameworks-2.yml b/.github/workflows/test-integrations-web-frameworks-2.yml index b5c44d7abe..a222e523b4 100644 --- a/.github/workflows/test-integrations-web-frameworks-2.yml +++ b/.github/workflows/test-integrations-web-frameworks-2.yml @@ -88,7 +88,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -170,7 +170,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db062694d..78aad7d292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## 2.16.0 + +### Integrations + +- Bottle: Add `failed_request_status_codes` (#3618) by @szokeasaurusrex + + You can now define a set of integers that will determine which status codes + should be reported to Sentry. + + ```python + sentry_sdk.init( + integrations=[ + BottleIntegration( + failed_request_status_codes={403, *range(500, 600)}, + ) + ] + ) + ``` + + Examples of valid `failed_request_status_codes`: + + - `{500}` will only send events on HTTP 500. + - `{400, *range(500, 600)}` will send events on HTTP 400 as well as the 5xx range. + - `{500, 503}` will send events on HTTP 500 and 503. + - `set()` (the empty set) will not send events for any HTTP status code. + + The default is `{*range(500, 600)}`, meaning that all 5xx status codes are reported to Sentry. + +- Bottle: Delete never-reached code (#3605) by @szokeasaurusrex +- Redis: Remove flaky test (#3626) by @sentrivana +- Django: Improve getting `psycopg3` connection info (#3580) by @nijel +- Django: Add `SpotlightMiddleware` when Spotlight is enabled (#3600) by @BYK +- Django: Open relevant error when `SpotlightMiddleware` is on (#3614) by @BYK +- Django: Support `http_methods_to_capture` in ASGI Django (#3607) by @sentrivana + + ASGI Django now also supports the `http_methods_to_capture` integration option. This is a configurable tuple of HTTP method verbs that should create a transaction in Sentry. The default is `("CONNECT", "DELETE", "GET", "PATCH", "POST", "PUT", "TRACE",)`. `OPTIONS` and `HEAD` are not included by default. + + Here's how to use it: + + ```python + sentry_sdk.init( + integrations=[ + DjangoIntegration( + http_methods_to_capture=("GET", "POST"), + ), + ], + ) + ``` + +### Miscellaneous + +- Add 3.13 to setup.py (#3574) by @sentrivana +- Add 3.13 to basepython (#3589) by @sentrivana +- Fix type of `sample_rate` in DSC (and add explanatory tests) (#3603) by @antonpirker +- Add `httpcore` based `HTTP2Transport` (#3588) by @BYK +- Add opportunistic Brotli compression (#3612) by @BYK +- Add `__notes__` support (#3620) by @szokeasaurusrex +- Remove useless makefile targets (#3604) by @antonpirker +- Simplify tox version spec (#3609) by @sentrivana +- Consolidate contributing docs (#3606) by @antonpirker +- Bump `codecov/codecov-action` from `4.5.0` to `4.6.0` (#3617) by @dependabot + ## 2.15.0 ### Integrations @@ -18,6 +80,7 @@ ), ], ) + ``` - Django: Allow ASGI to use `drf_request` in `DjangoRequestExtractor` (#3572) by @PakawiNz - Django: Don't let `RawPostDataException` bubble up (#3553) by @sentrivana diff --git a/docs/conf.py b/docs/conf.py index c1a219e278..390f576219 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,7 +28,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "2.15.0" +release = "2.16.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/requirements-testing.txt b/requirements-testing.txt index ce7bfa7962..851620c1d3 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -14,3 +14,4 @@ socksio httpcore[http2] setuptools freezegun +Brotli diff --git a/scripts/split-tox-gh-actions/templates/test_group.jinja b/scripts/split-tox-gh-actions/templates/test_group.jinja index f54ba1e413..38ab182b10 100644 --- a/scripts/split-tox-gh-actions/templates/test_group.jinja +++ b/scripts/split-tox-gh-actions/templates/test_group.jinja @@ -81,7 +81,7 @@ - name: Upload coverage to Codecov if: {% raw %}${{ !cancelled() }}{% endraw %} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} files: coverage.xml diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index eed60828b0..d66eb3653e 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -704,18 +704,16 @@ def capture_event( :returns: An event ID. May be `None` if there is no DSN set or of if the SDK decided to discard the event for other reasons. In such situations setting `debug=True` on `init()` may help. """ - if hint is None: - hint = {} - event_id = event.get("event_id") hint = dict(hint or ()) # type: Hint - if event_id is None: - event["event_id"] = event_id = uuid.uuid4().hex if not self._should_capture(event, hint, scope): return None profile = event.pop("profile", None) + event_id = event.get("event_id") + if event_id is None: + event["event_id"] = event_id = uuid.uuid4().hex event_opt = self._prepare_event(event, hint, scope) if event_opt is None: return None @@ -763,15 +761,16 @@ def capture_event( for attachment in attachments or (): envelope.add_item(attachment.to_envelope_item()) + return_value = None if self.spotlight: self.spotlight.capture_envelope(envelope) + return_value = event_id - if self.transport is None: - return None - - self.transport.capture_envelope(envelope) + if self.transport is not None: + self.transport.capture_envelope(envelope) + return_value = event_id - return event_id + return return_value def capture_session( self, session # type: Session diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 121c3f74da..2bd606f472 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -18,6 +18,11 @@ class EndpointType(Enum): ENVELOPE = "envelope" +class CompressionAlgo(Enum): + GZIP = "gzip" + BROTLI = "br" + + if TYPE_CHECKING: import sentry_sdk @@ -56,6 +61,8 @@ class EndpointType(Enum): "continuous_profiling_mode": Optional[ContinuousProfilerMode], "otel_powered_performance": Optional[bool], "transport_zlib_compression_level": Optional[int], + "transport_compression_level": Optional[int], + "transport_compression_algo": Optional[CompressionAlgo], "transport_num_pools": Optional[int], "transport_http2": Optional[bool], }, @@ -553,4 +560,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.15.0" +VERSION = "2.16.0" diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py index 6dae8d9188..a2d6b51033 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -9,13 +9,19 @@ parse_version, transaction_from_function, ) -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import ( + Integration, + DidNotEnable, + _DEFAULT_FAILED_REQUEST_STATUS_CODES, +) from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.integrations._wsgi_common import RequestExtractor from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Set + from sentry_sdk.integrations.wsgi import _ScopedResponse from typing import Any from typing import Dict @@ -28,6 +34,7 @@ try: from bottle import ( Bottle, + HTTPResponse, Route, request as bottle_request, __version__ as BOTTLE_VERSION, @@ -45,8 +52,13 @@ class BottleIntegration(Integration): transaction_style = "" - def __init__(self, transaction_style="endpoint"): - # type: (str) -> None + def __init__( + self, + transaction_style="endpoint", # type: str + *, + failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Set[int] + ): + # type: (...) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( @@ -54,6 +66,7 @@ def __init__(self, transaction_style="endpoint"): % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self.failed_request_status_codes = failed_request_status_codes @staticmethod def setup_once(): @@ -102,26 +115,29 @@ def _patched_handle(self, environ): old_make_callback = Route._make_callback - @ensure_integration_enabled(BottleIntegration, old_make_callback) + @functools.wraps(old_make_callback) def patched_make_callback(self, *args, **kwargs): # type: (Route, *object, **object) -> Any - client = sentry_sdk.get_client() prepared_callback = old_make_callback(self, *args, **kwargs) + integration = sentry_sdk.get_client().get_integration(BottleIntegration) + if integration is None: + return prepared_callback + def wrapped_callback(*args, **kwargs): # type: (*object, **object) -> Any - try: res = prepared_callback(*args, **kwargs) except Exception as exception: - event, hint = event_from_exception( - exception, - client_options=client.options, - mechanism={"type": "bottle", "handled": False}, - ) - sentry_sdk.capture_event(event, hint=hint) + _capture_exception(exception, handled=False) raise exception + if ( + isinstance(res, HTTPResponse) + and res.status_code in integration.failed_request_status_codes + ): + _capture_exception(res, handled=True) + return res return wrapped_callback @@ -191,3 +207,13 @@ def event_processor(event, hint): return event return event_processor + + +def _capture_exception(exception, handled): + # type: (BaseException, bool) -> None + event, hint = event_from_exception( + exception, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "bottle", "handled": handled}, + ) + sentry_sdk.capture_event(event, hint=hint) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index c9f20dd49b..e68f0cacef 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -717,8 +717,18 @@ def _set_db_data(span, cursor_or_db): connection_params = cursor_or_db.connection.get_dsn_parameters() else: try: - # psycopg3 - connection_params = cursor_or_db.connection.info.get_parameters() + # psycopg3, only extract needed params as get_parameters + # can be slow because of the additional logic to filter out default + # values + connection_params = { + "dbname": cursor_or_db.connection.info.dbname, + "port": cursor_or_db.connection.info.port, + } + # PGhost returns host or base dir of UNIX socket as an absolute path + # starting with /, use it only when it contains host + pg_host = cursor_or_db.connection.info.host + if pg_host and not pg_host.startswith("/"): + connection_params["host"] = pg_host except Exception: connection_params = db.get_connection_params() diff --git a/sentry_sdk/spotlight.py b/sentry_sdk/spotlight.py index 3e8072b5d8..e21bf56545 100644 --- a/sentry_sdk/spotlight.py +++ b/sentry_sdk/spotlight.py @@ -79,14 +79,22 @@ def process_exception(self, _request, exception): spotlight_url = spotlight_client.url.rsplit("/", 1)[0] try: - spotlight = ( - urllib.request.urlopen(spotlight_url).read().decode("utf-8") - ).replace("", f'') + spotlight = urllib.request.urlopen(spotlight_url).read().decode("utf-8") except urllib.error.URLError: return None else: - sentry_sdk.api.capture_exception(exception) - return HttpResponseServerError(spotlight) + event_id = sentry_sdk.api.capture_exception(exception) + return HttpResponseServerError( + spotlight.replace( + "", + ( + f'' + ''.format( + event_id=event_id + ) + ), + ) + ) except ImportError: settings = None diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index 5d71a4575a..d8e8d80810 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -10,6 +10,11 @@ from collections import defaultdict from urllib.request import getproxies +try: + import brotli # type: ignore +except ImportError: + brotli = None + import urllib3 import certifi @@ -29,6 +34,7 @@ from typing import List from typing import Mapping from typing import Optional + from typing import Self from typing import Tuple from typing import Type from typing import Union @@ -61,20 +67,16 @@ class Transport(ABC): parsed_dsn = None # type: Optional[Dsn] - def __init__( - self, options=None # type: Optional[Dict[str, Any]] - ): - # type: (...) -> None + def __init__(self, options=None): + # type: (Self, Optional[Dict[str, Any]]) -> None self.options = options if options and options["dsn"] is not None and options["dsn"]: self.parsed_dsn = Dsn(options["dsn"]) else: self.parsed_dsn = None - def capture_event( - self, event # type: Event - ): - # type: (...) -> None + def capture_event(self, event): + # type: (Self, Event) -> None """ DEPRECATED: Please use capture_envelope instead. @@ -93,25 +95,23 @@ def capture_event( self.capture_envelope(envelope) @abstractmethod - def capture_envelope( - self, envelope # type: Envelope - ): - # type: (...) -> None + def capture_envelope(self, envelope): + # type: (Self, Envelope) -> None """ Send an envelope to Sentry. Envelopes are a data container format that can hold any type of data submitted to Sentry. We use it to send all event data (including errors, - transactions, crons checkins, etc.) to Sentry. + transactions, crons check-ins, etc.) to Sentry. """ pass def flush( self, - timeout, # type: float - callback=None, # type: Optional[Any] + timeout, + callback=None, ): - # type: (...) -> None + # type: (Self, float, Optional[Any]) -> None """ Wait `timeout` seconds for the current events to be sent out. @@ -121,7 +121,7 @@ def flush( return None def kill(self): - # type: () -> None + # type: (Self) -> None """ Forcefully kills the transport. @@ -156,11 +156,11 @@ def record_lost_event( return None def is_healthy(self): - # type: () -> bool + # type: (Self) -> bool return True def __del__(self): - # type: () -> None + # type: (Self) -> None try: self.kill() except Exception: @@ -168,16 +168,16 @@ def __del__(self): def _parse_rate_limits(header, now=None): - # type: (Any, Optional[datetime]) -> Iterable[Tuple[Optional[EventDataCategory], datetime]] + # type: (str, Optional[datetime]) -> Iterable[Tuple[Optional[EventDataCategory], datetime]] if now is None: now = datetime.now(timezone.utc) for limit in header.split(","): try: parameters = limit.strip().split(":") - retry_after, categories = parameters[:2] + retry_after_val, categories = parameters[:2] - retry_after = now + timedelta(seconds=int(retry_after)) + retry_after = now + timedelta(seconds=int(retry_after_val)) for category in categories and categories.split(";") or (None,): yield category, retry_after except (LookupError, ValueError): @@ -187,10 +187,8 @@ def _parse_rate_limits(header, now=None): class BaseHttpTransport(Transport): """The base HTTP transport.""" - def __init__( - self, options # type: Dict[str, Any] - ): - # type: (...) -> None + def __init__(self, options): + # type: (Self, Dict[str, Any]) -> None from sentry_sdk.consts import VERSION Transport.__init__(self, options) @@ -206,13 +204,6 @@ def __init__( ) # type: DefaultDict[Tuple[EventDataCategory, str], int] self._last_client_report_sent = time.time() - compression_level = options.get("_experiments", {}).get( - "transport_zlib_compression_level" - ) - self._compression_level = ( - 9 if compression_level is None else int(compression_level) - ) - self._pool = self._make_pool( self.parsed_dsn, http_proxy=options["http_proxy"], @@ -223,6 +214,45 @@ def __init__( proxy_headers=options["proxy_headers"], ) + experiments = options.get("_experiments", {}) + compression_level = experiments.get( + "transport_compression_level", + experiments.get("transport_zlib_compression_level"), + ) + compression_algo = experiments.get( + "transport_compression_algo", + ( + "gzip" + # if only compression level is set, assume gzip for backwards compatibility + # if we don't have brotli available, fallback to gzip + if compression_level is not None or brotli is None + else "br" + ), + ) + + if compression_algo == "br" and brotli is None: + logger.warning( + "You asked for brotli compression without the Brotli module, falling back to gzip -9" + ) + compression_algo = "gzip" + compression_level = None + + if compression_algo not in ("br", "gzip"): + logger.warning( + "Unknown compression algo %s, disabling compression", compression_algo + ) + self._compression_level = 0 + self._compression_algo = None + else: + self._compression_algo = compression_algo + + if compression_level is not None: + self._compression_level = compression_level + elif self._compression_algo == "gzip": + self._compression_level = 9 + elif self._compression_algo == "br": + self._compression_level = 4 + def record_lost_event( self, reason, # type: str @@ -258,11 +288,11 @@ def record_lost_event( self._discarded_events[data_category, reason] += quantity def _get_header_value(self, response, header): - # type: (Any, str) -> Optional[str] + # type: (Self, Any, str) -> Optional[str] return response.headers.get(header) def _update_rate_limits(self, response): - # type: (Union[urllib3.BaseHTTPResponse, httpcore.Response]) -> None + # type: (Self, Union[urllib3.BaseHTTPResponse, httpcore.Response]) -> None # new sentries with more rate limit insights. We honor this header # no matter of the status code to update our internal rate limits. @@ -288,12 +318,12 @@ def _update_rate_limits(self, response): def _send_request( self, - body, # type: bytes - headers, # type: Dict[str, str] - endpoint_type=EndpointType.ENVELOPE, # type: EndpointType - envelope=None, # type: Optional[Envelope] + body, + headers, + endpoint_type=EndpointType.ENVELOPE, + envelope=None, ): - # type: (...) -> None + # type: (Self, bytes, Dict[str, str], EndpointType, Optional[Envelope]) -> None def record_loss(reason): # type: (str) -> None @@ -343,12 +373,12 @@ def record_loss(reason): finally: response.close() - def on_dropped_event(self, reason): - # type: (str) -> None + def on_dropped_event(self, _reason): + # type: (Self, str) -> None return None def _fetch_pending_client_report(self, force=False, interval=60): - # type: (bool, int) -> Optional[Item] + # type: (Self, bool, int) -> Optional[Item] if not self.options["send_client_reports"]: return None @@ -379,7 +409,7 @@ def _fetch_pending_client_report(self, force=False, interval=60): ) def _flush_client_reports(self, force=False): - # type: (bool) -> None + # type: (Self, bool) -> None client_report = self._fetch_pending_client_report(force=force, interval=60) if client_report is not None: self.capture_envelope(Envelope(items=[client_report])) @@ -394,23 +424,21 @@ def _disabled(bucket): return _disabled(category) or _disabled(None) def _is_rate_limited(self): - # type: () -> bool + # type: (Self) -> bool return any( ts > datetime.now(timezone.utc) for ts in self._disabled_until.values() ) def _is_worker_full(self): - # type: () -> bool + # type: (Self) -> bool return self._worker.full() def is_healthy(self): - # type: () -> bool + # type: (Self) -> bool return not (self._is_worker_full() or self._is_rate_limited()) - def _send_envelope( - self, envelope # type: Envelope - ): - # type: (...) -> None + def _send_envelope(self, envelope): + # type: (Self, Envelope) -> None # remove all items from the envelope which are over quota new_items = [] @@ -438,14 +466,7 @@ def _send_envelope( if client_report_item is not None: envelope.items.append(client_report_item) - body = io.BytesIO() - if self._compression_level == 0: - envelope.serialize_into(body) - else: - with gzip.GzipFile( - fileobj=body, mode="w", compresslevel=self._compression_level - ) as f: - envelope.serialize_into(f) + content_encoding, body = self._serialize_envelope(envelope) assert self.parsed_dsn is not None logger.debug( @@ -458,8 +479,8 @@ def _send_envelope( headers = { "Content-Type": "application/x-sentry-envelope", } - if self._compression_level > 0: - headers["Content-Encoding"] = "gzip" + if content_encoding: + headers["Content-Encoding"] = content_encoding self._send_request( body.getvalue(), @@ -469,12 +490,34 @@ def _send_envelope( ) return None + def _serialize_envelope(self, envelope): + # type: (Self, Envelope) -> tuple[Optional[str], io.BytesIO] + content_encoding = None + body = io.BytesIO() + if self._compression_level == 0 or self._compression_algo is None: + envelope.serialize_into(body) + else: + content_encoding = self._compression_algo + if self._compression_algo == "br" and brotli is not None: + body.write( + brotli.compress( + envelope.serialize(), quality=self._compression_level + ) + ) + else: # assume gzip as we sanitize the algo value in init + with gzip.GzipFile( + fileobj=body, mode="w", compresslevel=self._compression_level + ) as f: + envelope.serialize_into(f) + + return content_encoding, body + def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] + # type: (Self, Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] raise NotImplementedError() def _in_no_proxy(self, parsed_dsn): - # type: (Dsn) -> bool + # type: (Self, Dsn) -> bool no_proxy = getproxies().get("no") if not no_proxy: return False @@ -504,7 +547,7 @@ def _request( body, headers, ): - # type: (str, EndpointType, Any, Mapping[str, str]) -> Union[urllib3.BaseHTTPResponse, httpcore.Response] + # type: (Self, str, EndpointType, Any, Mapping[str, str]) -> Union[urllib3.BaseHTTPResponse, httpcore.Response] raise NotImplementedError() def capture_envelope( @@ -524,10 +567,10 @@ def send_envelope_wrapper(): def flush( self, - timeout, # type: float - callback=None, # type: Optional[Any] + timeout, + callback=None, ): - # type: (...) -> None + # type: (Self, float, Optional[Callable[[int, float], None]]) -> None logger.debug("Flushing HTTP transport") if timeout > 0: @@ -535,7 +578,7 @@ def flush( self._worker.flush(timeout, callback) def kill(self): - # type: () -> None + # type: (Self) -> None logger.debug("Killing HTTP transport") self._worker.kill() @@ -545,7 +588,7 @@ class HttpTransport(BaseHttpTransport): _pool: Union[PoolManager, ProxyManager] def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] + # type: (Self, Any, Any, Any) -> Dict[str, Any] num_pools = self.options.get("_experiments", {}).get("transport_num_pools") options = { @@ -587,9 +630,9 @@ def _make_pool( parsed_dsn, # type: Dsn http_proxy, # type: Optional[str] https_proxy, # type: Optional[str] - ca_certs, # type: Optional[Any] - cert_file, # type: Optional[Any] - key_file, # type: Optional[Any] + ca_certs, # type: Any + cert_file, # type: Any + key_file, # type: Any proxy_headers, # type: Optional[Dict[str, str]] ): # type: (...) -> Union[PoolManager, ProxyManager] @@ -638,7 +681,7 @@ def _request( body, headers, ): - # type: (str, EndpointType, Any, Mapping[str, str]) -> urllib3.BaseHTTPResponse + # type: (Self, str, EndpointType, Any, Mapping[str, str]) -> urllib3.BaseHTTPResponse return self._pool.request( method, self._auth.get_api_url(endpoint_type), @@ -652,10 +695,8 @@ def _request( except ImportError: # Sorry, no Http2Transport for you class Http2Transport(HttpTransport): - def __init__( - self, options # type: Dict[str, Any] - ): - # type: (...) -> None + def __init__(self, options): + # type: (Self, Dict[str, Any]) -> None super().__init__(options) logger.warning( "You tried to use HTTP2Transport but don't have httpcore[http2] installed. Falling back to HTTPTransport." @@ -672,7 +713,7 @@ class Http2Transport(BaseHttpTransport): # type: ignore ] def _get_header_value(self, response, header): - # type: (httpcore.Response, str) -> Optional[str] + # type: (Self, httpcore.Response, str) -> Optional[str] return next( ( val.decode("ascii") @@ -689,7 +730,7 @@ def _request( body, headers, ): - # type: (str, EndpointType, Any, Mapping[str, str]) -> httpcore.Response + # type: (Self, str, EndpointType, Any, Mapping[str, str]) -> httpcore.Response response = self._pool.request( method, self._auth.get_api_url(endpoint_type), @@ -699,7 +740,7 @@ def _request( return response def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] + # type: (Any, Any, Any) -> Dict[str, Any] options = { "http2": True, "retries": 3, @@ -739,9 +780,9 @@ def _make_pool( parsed_dsn, # type: Dsn http_proxy, # type: Optional[str] https_proxy, # type: Optional[str] - ca_certs, # type: Optional[Any] - cert_file, # type: Optional[Any] - key_file, # type: Optional[Any] + ca_certs, # type: Any + cert_file, # type: Any + key_file, # type: Any proxy_headers, # type: Optional[Dict[str, str]] ): # type: (...) -> Union[httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool] diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 5fb3cf604c..80a5df9700 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -30,8 +30,6 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from collections.abc import Awaitable - from types import FrameType, TracebackType from typing import ( Any, @@ -687,11 +685,21 @@ def get_errno(exc_value): def get_error_message(exc_value): # type: (Optional[BaseException]) -> str - return ( + message = ( getattr(exc_value, "message", "") or getattr(exc_value, "detail", "") or safe_str(exc_value) - ) + ) # type: str + + # __notes__ should be a list of strings when notes are added + # via add_note, but can be anything else if __notes__ is set + # directly. We only support strings in __notes__, since that + # is the correct use. + notes = getattr(exc_value, "__notes__", None) # type: object + if isinstance(notes, list) and len(notes) > 0: + message += "\n" + "\n".join(note for note in notes if isinstance(note, str)) + + return message def single_exception_from_error_tuple( @@ -1681,12 +1689,6 @@ def _no_op(*_a, **_k): pass -async def _no_op_async(*_a, **_k): - # type: (*Any, **Any) -> None - """No-op function for ensure_integration_enabled_async.""" - pass - - if TYPE_CHECKING: @overload @@ -1753,59 +1755,6 @@ def runner(*args: "P.args", **kwargs: "P.kwargs"): return patcher -if TYPE_CHECKING: - - # mypy has some trouble with the overloads, hence the ignore[no-overload-impl] - @overload # type: ignore[no-overload-impl] - def ensure_integration_enabled_async( - integration, # type: type[sentry_sdk.integrations.Integration] - original_function, # type: Callable[P, Awaitable[R]] - ): - # type: (...) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]] - ... - - @overload - def ensure_integration_enabled_async( - integration, # type: type[sentry_sdk.integrations.Integration] - ): - # type: (...) -> Callable[[Callable[P, Awaitable[None]]], Callable[P, Awaitable[None]]] - ... - - -# The ignore[no-redef] also needed because mypy is struggling with these overloads. -def ensure_integration_enabled_async( # type: ignore[no-redef] - integration, # type: type[sentry_sdk.integrations.Integration] - original_function=_no_op_async, # type: Union[Callable[P, Awaitable[R]], Callable[P, Awaitable[None]]] -): - # type: (...) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]] - """ - Version of `ensure_integration_enabled` for decorating async functions. - - Please refer to the `ensure_integration_enabled` documentation for more information. - """ - - if TYPE_CHECKING: - # Type hint to ensure the default function has the right typing. The overloads - # ensure the default _no_op function is only used when R is None. - original_function = cast(Callable[P, Awaitable[R]], original_function) - - def patcher(sentry_patched_function): - # type: (Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]] - async def runner(*args: "P.args", **kwargs: "P.kwargs"): - # type: (...) -> R - if sentry_sdk.get_client().get_integration(integration) is None: - return await original_function(*args, **kwargs) - - return await sentry_patched_function(*args, **kwargs) - - if original_function is _no_op_async: - return wraps(sentry_patched_function)(runner) - - return wraps(original_function)(runner) - - return patcher - - def now(): # type: () -> float return time.perf_counter() diff --git a/setup.py b/setup.py index 24f1203406..bb72fe2b31 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.15.0", + version="2.16.0", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", @@ -99,6 +99,7 @@ def get_file_text(file_name): "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", ], options={"bdist_wheel": {"universal": "1"}}, diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 5b25629a83..cd65e7cdd5 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -55,7 +55,7 @@ async def hello(request): assert request["url"] == "http://{host}/".format(host=host) assert request["headers"] == { "Accept": "*/*", - "Accept-Encoding": "gzip, deflate", + "Accept-Encoding": mock.ANY, "Host": host, "User-Agent": request["headers"]["User-Agent"], "baggage": mock.ANY, diff --git a/tests/integrations/bottle/test_bottle.py b/tests/integrations/bottle/test_bottle.py index 9dd23cf45a..9cc436a229 100644 --- a/tests/integrations/bottle/test_bottle.py +++ b/tests/integrations/bottle/test_bottle.py @@ -3,12 +3,14 @@ import logging from io import BytesIO -from bottle import Bottle, debug as set_debug, abort, redirect +from bottle import Bottle, debug as set_debug, abort, redirect, HTTPResponse from sentry_sdk import capture_message +from sentry_sdk.integrations.bottle import BottleIntegration from sentry_sdk.serializer import MAX_DATABAG_BREADTH from sentry_sdk.integrations.logging import LoggingIntegration from werkzeug.test import Client +from werkzeug.wrappers import Response import sentry_sdk.integrations.bottle as bottle_sentry @@ -445,3 +447,80 @@ def test_span_origin( (_, event) = events assert event["contexts"]["trace"]["origin"] == "auto.http.bottle" + + +@pytest.mark.parametrize("raise_error", [True, False]) +@pytest.mark.parametrize( + ("integration_kwargs", "status_code", "should_capture"), + ( + ({}, None, False), + ({}, 400, False), + ({}, 451, False), # Highest 4xx status code + ({}, 500, True), + ({}, 511, True), # Highest 5xx status code + ({"failed_request_status_codes": set()}, 500, False), + ({"failed_request_status_codes": set()}, 511, False), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 404, True), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 500, True), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 400, False), + ), +) +def test_failed_request_status_codes( + sentry_init, + capture_events, + integration_kwargs, + status_code, + should_capture, + raise_error, +): + sentry_init(integrations=[BottleIntegration(**integration_kwargs)]) + events = capture_events() + + app = Bottle() + + @app.route("/") + def handle(): + if status_code is not None: + response = HTTPResponse(status=status_code) + if raise_error: + raise response + else: + return response + return "OK" + + client = Client(app, Response) + response = client.get("/") + + expected_status = 200 if status_code is None else status_code + assert response.status_code == expected_status + + if should_capture: + (event,) = events + assert event["exception"]["values"][0]["type"] == "HTTPResponse" + else: + assert not events + + +def test_failed_request_status_codes_non_http_exception(sentry_init, capture_events): + """ + If an exception, which is not an instance of HTTPResponse, is raised, it should be captured, even if + failed_request_status_codes is empty. + """ + sentry_init(integrations=[BottleIntegration(failed_request_status_codes=set())]) + events = capture_events() + + app = Bottle() + + @app.route("/") + def handle(): + 1 / 0 + + client = Client(app, Response) + + try: + client.get("/") + except ZeroDivisionError: + pass + + (event,) = events + assert event["exception"]["values"][0]["type"] == "ZeroDivisionError" diff --git a/tests/test_basics.py b/tests/test_basics.py index 62122b3fff..749d31d7d3 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -30,7 +30,6 @@ setup_integrations, ) from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.integrations.stdlib import StdlibIntegration from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import get_sdk_name, reraise @@ -748,13 +747,6 @@ def test_functions_to_trace_with_class(sentry_init, capture_events): assert event["spans"][1]["description"] == "tests.test_basics.WorldGreeter.greet" -def test_redis_disabled_when_not_installed(sentry_init): - with ModuleImportErrorSimulator(["redis"], ImportError): - sentry_init() - - assert sentry_sdk.get_client().get_integration(RedisIntegration) is None - - def test_multiple_setup_integrations_calls(): first_call_return = setup_integrations([NoOpIntegration()], with_defaults=False) assert first_call_return == {NoOpIntegration.identifier: NoOpIntegration()} @@ -842,3 +834,46 @@ def test_last_event_id_scope(sentry_init): # Should not crash with isolation_scope() as scope: assert scope.last_event_id() is None + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="add_note() not supported") +def test_notes(sentry_init, capture_events): + sentry_init() + events = capture_events() + try: + e = ValueError("aha!") + e.add_note("Test 123") + e.add_note("another note") + raise e + except Exception: + capture_exception() + + (event,) = events + + assert event["exception"]["values"][0]["value"] == "aha!\nTest 123\nanother note" + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="add_note() not supported") +def test_notes_safe_str(sentry_init, capture_events): + class Note2: + def __repr__(self): + raise TypeError + + def __str__(self): + raise TypeError + + sentry_init() + events = capture_events() + try: + e = ValueError("aha!") + e.add_note("note 1") + e.__notes__.append(Note2()) # type: ignore + e.add_note("note 3") + e.__notes__.append(2) # type: ignore + raise e + except Exception: + capture_exception() + + (event,) = events + + assert event["exception"]["values"][0]["value"] == "aha!\nnote 1\nnote 3" diff --git a/tests/test_transport.py b/tests/test_transport.py index fdb360826f..2a326fbdfb 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone from unittest import mock +import brotli import pytest from pytest_localserver.http import WSGIServer from werkzeug.wrappers import Request, Response @@ -52,9 +53,13 @@ def __call__(self, environ, start_response): """ request = Request(environ) event = envelope = None - if request.headers.get("content-encoding") == "gzip": + content_encoding = request.headers.get("content-encoding") + if content_encoding == "gzip": rdr = gzip.GzipFile(fileobj=io.BytesIO(request.data)) compressed = True + elif content_encoding == "br": + rdr = io.BytesIO(brotli.decompress(request.data)) + compressed = True else: rdr = io.BytesIO(request.data) compressed = False @@ -115,7 +120,8 @@ def mock_transaction_envelope(span_count): @pytest.mark.parametrize("debug", (True, False)) @pytest.mark.parametrize("client_flush_method", ["close", "flush"]) @pytest.mark.parametrize("use_pickle", (True, False)) -@pytest.mark.parametrize("compression_level", (0, 9)) +@pytest.mark.parametrize("compression_level", (0, 9, None)) +@pytest.mark.parametrize("compression_algo", ("gzip", "br", "", None)) @pytest.mark.parametrize( "http2", [True, False] if sys.version_info >= (3, 8) else [False] ) @@ -129,14 +135,18 @@ def test_transport_works( client_flush_method, use_pickle, compression_level, + compression_algo, http2, maybe_monkeypatched_threading, ): caplog.set_level(logging.DEBUG) - experiments = { - "transport_zlib_compression_level": compression_level, - } + experiments = {} + if compression_level is not None: + experiments["transport_compression_level"] = compression_level + + if compression_algo is not None: + experiments["transport_compression_algo"] = compression_algo if http2: experiments["transport_http2"] = True @@ -162,7 +172,21 @@ def test_transport_works( out, err = capsys.readouterr() assert not err and not out assert capturing_server.captured - assert capturing_server.captured[0].compressed == (compression_level > 0) + should_compress = ( + # default is to compress with brotli if available, gzip otherwise + (compression_level is None) + or ( + # setting compression level to 0 means don't compress + compression_level + > 0 + ) + ) and ( + # if we couldn't resolve to a known algo, we don't compress + compression_algo + != "" + ) + + assert capturing_server.captured[0].compressed == should_compress assert any("Sending envelope" in record.msg for record in caplog.records) == debug diff --git a/tests/test_utils.py b/tests/test_utils.py index 4df343a357..6745e2a966 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -30,14 +30,12 @@ _get_installed_modules, _generate_installed_modules, ensure_integration_enabled, - ensure_integration_enabled_async, ) class TestIntegration(Integration): """ - Test integration for testing ensure_integration_enabled and - ensure_integration_enabled_async decorators. + Test integration for testing ensure_integration_enabled decorator. """ identifier = "test" @@ -733,90 +731,6 @@ def function_to_patch(): assert patched_function.__name__ == "function_to_patch" -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_integration_enabled(sentry_init): - # Setup variables and functions for the test - async def original_function(): - return "original" - - async def function_to_patch(): - return "patched" - - sentry_init(integrations=[TestIntegration()]) - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async( - TestIntegration, original_function - )(function_to_patch) - - assert await patched_function() == "patched" - assert patched_function.__name__ == "original_function" - - -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_integration_disabled(sentry_init): - # Setup variables and functions for the test - async def original_function(): - return "original" - - async def function_to_patch(): - return "patched" - - sentry_init(integrations=[]) # TestIntegration is disabled - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async( - TestIntegration, original_function - )(function_to_patch) - - assert await patched_function() == "original" - assert patched_function.__name__ == "original_function" - - -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_no_original_function_enabled( - sentry_init, -): - shared_variable = "original" - - async def function_to_patch(): - nonlocal shared_variable - shared_variable = "patched" - - sentry_init(integrations=[TestIntegration]) - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async(TestIntegration)( - function_to_patch - ) - await patched_function() - - assert shared_variable == "patched" - assert patched_function.__name__ == "function_to_patch" - - -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_no_original_function_disabled( - sentry_init, -): - shared_variable = "original" - - async def function_to_patch(): - nonlocal shared_variable - shared_variable = "patched" - - sentry_init(integrations=[]) - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async(TestIntegration)( - function_to_patch - ) - await patched_function() - - assert shared_variable == "original" - assert patched_function.__name__ == "function_to_patch" - - @pytest.mark.parametrize( "delta,expected_milliseconds", [ diff --git a/tox.ini b/tox.ini index 6dd3a367e5..ebe8d06506 100644 --- a/tox.ini +++ b/tox.ini @@ -756,6 +756,7 @@ basepython = py3.10: python3.10 py3.11: python3.11 py3.12: python3.12 + py3.13: python3.13 # Python version is pinned here because flake8 actually behaves differently # depending on which version is used. You can patch this out to point to