diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 17d8a34dc5..31f71b14f1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: false +blank_issues_enabled: true contact_links: - name: Support Request url: https://sentry.io/support diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 6653e989be..2039a00b35 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -36,9 +36,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -78,6 +79,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml test-ai-pinned: name: AI (pinned) timeout-minutes: 30 @@ -96,9 +98,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -138,6 +141,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All AI tests passed needs: test-ai-pinned diff --git a/.github/workflows/test-integrations-aws-lambda.yml b/.github/workflows/test-integrations-aws-lambda.yml index 8f8cbc18f1..119545c9f6 100644 --- a/.github/workflows/test-integrations-aws-lambda.yml +++ b/.github/workflows/test-integrations-aws-lambda.yml @@ -71,9 +71,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -97,6 +98,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All AWS Lambda tests passed needs: test-aws_lambda-pinned diff --git a/.github/workflows/test-integrations-cloud-computing.yml b/.github/workflows/test-integrations-cloud-computing.yml index e2bab93dc1..531303bf52 100644 --- a/.github/workflows/test-integrations-cloud-computing.yml +++ b/.github/workflows/test-integrations-cloud-computing.yml @@ -36,9 +36,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -74,6 +75,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml test-cloud_computing-pinned: name: Cloud Computing (pinned) timeout-minutes: 30 @@ -92,9 +94,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -130,6 +133,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All Cloud Computing tests passed needs: test-cloud_computing-pinned diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index 4b1b13f289..a32f300512 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12"] + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13"] # 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 @@ -36,9 +36,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -62,6 +63,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All Common tests passed needs: test-common-pinned diff --git a/.github/workflows/test-integrations-data-processing.yml b/.github/workflows/test-integrations-data-processing.yml index 5d768bb7d0..1585adb20e 100644 --- a/.github/workflows/test-integrations-data-processing.yml +++ b/.github/workflows/test-integrations-data-processing.yml @@ -36,11 +36,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Start Redis uses: supercharge/redis-github-action@1.8.0 - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -84,6 +85,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml test-data_processing-pinned: name: Data Processing (pinned) timeout-minutes: 30 @@ -102,11 +104,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Start Redis uses: supercharge/redis-github-action@1.8.0 - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -150,6 +153,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All Data Processing tests passed needs: test-data_processing-pinned diff --git a/.github/workflows/test-integrations-databases.yml b/.github/workflows/test-integrations-databases.yml index d0ecc89c94..c547e1a9da 100644 --- a/.github/workflows/test-integrations-databases.yml +++ b/.github/workflows/test-integrations-databases.yml @@ -54,10 +54,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - uses: getsentry/action-clickhouse-in-ci@v1 - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -101,6 +102,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml test-databases-pinned: name: Databases (pinned) timeout-minutes: 30 @@ -137,10 +139,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - uses: getsentry/action-clickhouse-in-ci@v1 - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -184,6 +187,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All Databases tests passed needs: test-databases-pinned diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index dd17bf51ec..d5f78aaa89 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -36,9 +36,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -74,6 +75,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml test-graphql-pinned: name: GraphQL (pinned) timeout-minutes: 30 @@ -92,9 +94,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -130,6 +133,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All GraphQL tests passed needs: test-graphql-pinned diff --git a/.github/workflows/test-integrations-miscellaneous.yml b/.github/workflows/test-integrations-miscellaneous.yml index 982b8613c8..71ee0a2f1c 100644 --- a/.github/workflows/test-integrations-miscellaneous.yml +++ b/.github/workflows/test-integrations-miscellaneous.yml @@ -36,9 +36,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -78,6 +79,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml test-miscellaneous-pinned: name: Miscellaneous (pinned) timeout-minutes: 30 @@ -96,9 +98,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -138,6 +141,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All Miscellaneous tests passed needs: test-miscellaneous-pinned diff --git a/.github/workflows/test-integrations-networking.yml b/.github/workflows/test-integrations-networking.yml index ac36574425..295f6bcffc 100644 --- a/.github/workflows/test-integrations-networking.yml +++ b/.github/workflows/test-integrations-networking.yml @@ -36,9 +36,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -74,6 +75,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml test-networking-pinned: name: Networking (pinned) timeout-minutes: 30 @@ -92,9 +94,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -130,6 +133,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All Networking tests passed needs: test-networking-pinned diff --git a/.github/workflows/test-integrations-web-frameworks-1.yml b/.github/workflows/test-integrations-web-frameworks-1.yml index 743a97cfa0..835dd724b3 100644 --- a/.github/workflows/test-integrations-web-frameworks-1.yml +++ b/.github/workflows/test-integrations-web-frameworks-1.yml @@ -54,9 +54,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -92,6 +93,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml test-web_frameworks_1-pinned: name: Web Frameworks 1 (pinned) timeout-minutes: 30 @@ -128,9 +130,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -166,6 +169,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All Web Frameworks 1 tests passed needs: test-web_frameworks_1-pinned diff --git a/.github/workflows/test-integrations-web-frameworks-2.yml b/.github/workflows/test-integrations-web-frameworks-2.yml index 09d179271a..37d00f8fbf 100644 --- a/.github/workflows/test-integrations-web-frameworks-2.yml +++ b/.github/workflows/test-integrations-web-frameworks-2.yml @@ -36,9 +36,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -94,6 +95,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml test-web_frameworks_2-pinned: name: Web Frameworks 2 (pinned) timeout-minutes: 30 @@ -112,9 +114,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Setup Test Env run: | - pip install coverage tox + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -170,6 +173,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml check_required_tests: name: All Web Frameworks 2 tests passed needs: test-web_frameworks_2-pinned diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6050b50e..158ccde21b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 2.11.0 + +### Various fixes & improvements + +- Add `disabled_integrations` (#3328) by @sentrivana + + Disabling individual integrations is now much easier. + Instead of disabling all automatically enabled integrations and specifying the ones + you want to keep, you can now use the new + [`disabled_integrations`](https://docs.sentry.io/platforms/python/configuration/options/#auto-enabling-integrations) + config option to provide a list of integrations to disable: + + ```python + import sentry_sdk + from sentry_sdk.integrations.flask import FlaskIntegration + + sentry_sdk.init( + # Do not use the Flask integration even if Flask is installed. + disabled_integrations=[ + FlaskIntegration(), + ], + ) + ``` + +- Use operation name as transaction name in Strawberry (#3294) by @sentrivana +- WSGI integrations respect `SCRIPT_NAME` env variable (#2622) by @sarvaSanjay +- Make Django DB spans have origin `auto.db.django` (#3319) by @antonpirker +- Sort breadcrumbs by time before sending (#3307) by @antonpirker +- Fix `KeyError('sentry-monitor-start-timestamp-s')` (#3278) by @Mohsen-Khodabakhshi +- Set MongoDB tags directly on span data (#3290) by @0Calories +- Lower logger level for some messages (#3305) by @sentrivana and @antonpirker +- Emit deprecation warnings from `Hub` API (#3280) by @szokeasaurusrex +- Clarify that `instrumenter` is internal-only (#3299) by @szokeasaurusrex +- Support Django 5.1 (#3207) by @sentrivana +- Remove apparently unnecessary `if` (#3298) by @szokeasaurusrex +- Preliminary support for Python 3.13 (#3200) by @sentrivana +- Move `sentry_sdk.init` out of `hub.py` (#3276) by @szokeasaurusrex +- Unhardcode integration list (#3240) by @rominf +- Allow passing of PostgreSQL port in tests (#3281) by @rominf +- Add tests for `@ai_track` decorator (#3325) by @colin-sentry +- Do not include type checking code in coverage report (#3327) by @antonpirker +- Fix test_installed_modules (#3309) by @szokeasaurusrex +- Fix typos and grammar in a comment (#3293) by @szokeasaurusrex +- Fixed failed tests setup (#3303) by @antonpirker +- Only assert warnings we are interested in (#3314) by @szokeasaurusrex + ## 2.10.0 ### Various fixes & improvements diff --git a/docs/conf.py b/docs/conf.py index ed2fe5b452..fc485b9d9a 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.10.0" +release = "2.11.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/pyproject.toml b/pyproject.toml index 20ee9680f7..a2d2e0f7d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,3 +8,7 @@ extend-exclude = ''' | .*_pb2_grpc.py # exclude autogenerated Protocol Buffer files anywhere in the project ) ''' +[tool.coverage.report] + exclude_also = [ + "if TYPE_CHECKING:", + ] \ No newline at end of file diff --git a/pytest.ini b/pytest.ini index c3f7a6b1e8..bece12f986 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -addopts = -vvv -rfEs -s --durations=5 --cov=tests --cov=sentry_sdk --cov-branch --cov-report= --tb=short --junitxml=.junitxml-{envname} +addopts = -vvv -rfEs -s --durations=5 --cov=tests --cov=sentry_sdk --cov-branch --cov-report= --tb=short --junitxml=.junitxml asyncio_mode = strict markers = tests_internal_exceptions: Handle internal exceptions just as the SDK does, to test it. (Otherwise internal exceptions are recorded and reraised.) diff --git a/scripts/runtox.sh b/scripts/runtox.sh index 146af7c665..6acf4406fb 100755 --- a/scripts/runtox.sh +++ b/scripts/runtox.sh @@ -25,8 +25,6 @@ done searchstring="$1" -export TOX_PARALLEL_NO_SPINNER=1 - if $excludelatest; then echo "Excluding latest" ENV="$($TOXPATH -l | grep -- "$searchstring" | grep -v -- '-latest' | tr $'\n' ',')" diff --git a/scripts/split-tox-gh-actions/templates/test_group.jinja b/scripts/split-tox-gh-actions/templates/test_group.jinja index dcf3a3734b..43d7081446 100644 --- a/scripts/split-tox-gh-actions/templates/test_group.jinja +++ b/scripts/split-tox-gh-actions/templates/test_group.jinja @@ -49,6 +49,7 @@ - uses: actions/setup-python@v5 with: python-version: {% raw %}${{ matrix.python-version }}{% endraw %} + allow-prereleases: true {% if needs_clickhouse %} - uses: getsentry/action-clickhouse-in-ci@v1 {% endif %} @@ -60,8 +61,7 @@ - name: Setup Test Env run: | - pip install coverage tox - + pip install "coverage[toml]" tox - name: Erase coverage run: | coverage erase @@ -94,4 +94,5 @@ if: {% raw %}${{ !cancelled() }}{% endraw %} uses: codecov/test-results-action@v1 with: - token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} \ No newline at end of file + token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} + files: .junitxml \ No newline at end of file diff --git a/sentry_sdk/__init__.py b/sentry_sdk/__init__.py index 94d97a87d8..f74c20a194 100644 --- a/sentry_sdk/__init__.py +++ b/sentry_sdk/__init__.py @@ -1,7 +1,8 @@ -from sentry_sdk.hub import Hub, init +from sentry_sdk.hub import Hub from sentry_sdk.scope import Scope from sentry_sdk.transport import Transport, HttpTransport from sentry_sdk.client import Client +from sentry_sdk._init_implementation import init from sentry_sdk.api import * # noqa diff --git a/sentry_sdk/_init_implementation.py b/sentry_sdk/_init_implementation.py new file mode 100644 index 0000000000..382b82acac --- /dev/null +++ b/sentry_sdk/_init_implementation.py @@ -0,0 +1,63 @@ +from typing import TYPE_CHECKING + +import sentry_sdk + +if TYPE_CHECKING: + from typing import Any, ContextManager, Optional + + import sentry_sdk.consts + + +class _InitGuard: + def __init__(self, client): + # type: (sentry_sdk.Client) -> None + self._client = client + + def __enter__(self): + # type: () -> _InitGuard + return self + + def __exit__(self, exc_type, exc_value, tb): + # type: (Any, Any, Any) -> None + c = self._client + if c is not None: + c.close() + + +def _check_python_deprecations(): + # type: () -> None + # Since we're likely to deprecate Python versions in the future, I'm keeping + # this handy function around. Use this to detect the Python version used and + # to output logger.warning()s if it's deprecated. + pass + + +def _init(*args, **kwargs): + # type: (*Optional[str], **Any) -> ContextManager[Any] + """Initializes the SDK and optionally integrations. + + This takes the same arguments as the client constructor. + """ + client = sentry_sdk.Client(*args, **kwargs) + sentry_sdk.Scope.get_global_scope().set_client(client) + _check_python_deprecations() + rv = _InitGuard(client) + return rv + + +if TYPE_CHECKING: + # Make mypy, PyCharm and other static analyzers think `init` is a type to + # have nicer autocompletion for params. + # + # Use `ClientConstructor` to define the argument types of `init` and + # `ContextManager[Any]` to tell static analyzers about the return type. + + class init(sentry_sdk.consts.ClientConstructor, _InitGuard): # noqa: N801 + pass + +else: + # Alias `init` for actual usage. Go through the lambda indirection to throw + # PyCharm off of the weakly typed signature (it would otherwise discover + # both the weakly typed signature of `_init` and our faked `init` type). + + init = (lambda: _init)() diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index 3dd6f9c737..41c4814146 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -322,7 +322,8 @@ def start_transaction( :param transaction: The transaction to start. If omitted, we create and start a new transaction. - :param instrumenter: This parameter is meant for internal use only. + :param instrumenter: This parameter is meant for internal use only. It + will be removed in the next major version. :param custom_sampling_context: The transaction's custom sampling context. :param kwargs: Optional keyword arguments to be passed to the Transaction constructor. See :py:class:`sentry_sdk.tracing.Transaction` for diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index f93aa935c2..1b5d8b7696 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -271,7 +271,6 @@ def _setup_instrumentation(self, functions_to_trace): function_obj = getattr(module_obj, function_name) setattr(module_obj, function_name, trace(function_obj)) logger.debug("Enabled tracing for %s", function_qualname) - except module_not_found_error: try: # Try to import a class @@ -372,6 +371,7 @@ def _capture_envelope(envelope): with_auto_enabling_integrations=self.options[ "auto_enabling_integrations" ], + disabled_integrations=self.options["disabled_integrations"], ) self.spotlight = None diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index b4d30cd24a..9a7823dbfb 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -514,6 +514,7 @@ def __init__( profiles_sampler=None, # type: Optional[TracesSampler] profiler_mode=None, # type: Optional[ProfilerMode] auto_enabling_integrations=True, # type: bool + disabled_integrations=None, # type: Optional[Sequence[Integration]] auto_session_tracking=True, # type: bool send_client_reports=True, # type: bool _experiments={}, # type: Experiments # noqa: B006 @@ -562,4 +563,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.10.0" +VERSION = "2.11.0" diff --git a/sentry_sdk/hub.py b/sentry_sdk/hub.py index 81abff8b5c..d514c168fa 100644 --- a/sentry_sdk/hub.py +++ b/sentry_sdk/hub.py @@ -1,3 +1,4 @@ +import warnings from contextlib import contextmanager from sentry_sdk._compat import with_metaclass @@ -44,7 +45,6 @@ LogLevelStr, SamplingContext, ) - from sentry_sdk.consts import ClientConstructor from sentry_sdk.tracing import TransactionKwargs T = TypeVar("T") @@ -56,64 +56,33 @@ def overload(x): return x -_local = ContextVar("sentry_current_hub") - - -class _InitGuard: - def __init__(self, client): - # type: (Client) -> None - self._client = client - - def __enter__(self): - # type: () -> _InitGuard - return self - - def __exit__(self, exc_type, exc_value, tb): - # type: (Any, Any, Any) -> None - c = self._client - if c is not None: - c.close() - - -def _check_python_deprecations(): - # type: () -> None - # Since we're likely to deprecate Python versions in the future, I'm keeping - # this handy function around. Use this to detect the Python version used and - # to output logger.warning()s if it's deprecated. - pass - - -def _init(*args, **kwargs): - # type: (*Optional[str], **Any) -> ContextManager[Any] - """Initializes the SDK and optionally integrations. - - This takes the same arguments as the client constructor. +class SentryHubDeprecationWarning(DeprecationWarning): + """ + A custom deprecation warning to inform users that the Hub is deprecated. """ - client = Client(*args, **kwargs) - Scope.get_global_scope().set_client(client) - _check_python_deprecations() - rv = _InitGuard(client) - return rv + _MESSAGE = ( + "`sentry_sdk.Hub` is deprecated and will be removed in a future major release. " + "Please consult our 1.x to 2.x migration guide for details on how to migrate " + "`Hub` usage to the new API: " + "https://docs.sentry.io/platforms/python/migration/1.x-to-2.x" + ) -from sentry_sdk._types import TYPE_CHECKING + def __init__(self, *_): + # type: (*object) -> None + super().__init__(self._MESSAGE) -if TYPE_CHECKING: - # Make mypy, PyCharm and other static analyzers think `init` is a type to - # have nicer autocompletion for params. - # - # Use `ClientConstructor` to define the argument types of `init` and - # `ContextManager[Any]` to tell static analyzers about the return type. - class init(ClientConstructor, _InitGuard): # noqa: N801 - pass +@contextmanager +def _suppress_hub_deprecation_warning(): + # type: () -> Generator[None, None, None] + """Utility function to suppress deprecation warnings for the Hub.""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=SentryHubDeprecationWarning) + yield -else: - # Alias `init` for actual usage. Go through the lambda indirection to throw - # PyCharm off of the weakly typed signature (it would otherwise discover - # both the weakly typed signature of `_init` and our faked `init` type). - init = (lambda: _init)() +_local = ContextVar("sentry_current_hub") class HubMeta(type): @@ -121,9 +90,12 @@ class HubMeta(type): def current(cls): # type: () -> Hub """Returns the current instance of the hub.""" + warnings.warn(SentryHubDeprecationWarning(), stacklevel=2) rv = _local.get(None) if rv is None: - rv = Hub(GLOBAL_HUB) + with _suppress_hub_deprecation_warning(): + # This will raise a deprecation warning; supress it since we already warned above. + rv = Hub(GLOBAL_HUB) _local.set(rv) return rv @@ -131,6 +103,7 @@ def current(cls): def main(cls): # type: () -> Hub """Returns the main instance of the hub.""" + warnings.warn(SentryHubDeprecationWarning(), stacklevel=2) return GLOBAL_HUB @@ -161,6 +134,7 @@ def __init__( scope=None, # type: Optional[Any] ): # type: (...) -> None + warnings.warn(SentryHubDeprecationWarning(), stacklevel=2) current_scope = None @@ -747,7 +721,10 @@ def trace_propagation_meta(self, span=None): ) -GLOBAL_HUB = Hub() +with _suppress_hub_deprecation_warning(): + # Suppress deprecation warning for the Hub here, since we still always + # import this module. + GLOBAL_HUB = Hub() _local.set(GLOBAL_HUB) diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 9e3b11f318..3c43ed5472 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -6,10 +6,12 @@ if TYPE_CHECKING: + from collections.abc import Sequence from typing import Callable from typing import Dict from typing import Iterator from typing import List + from typing import Optional from typing import Set from typing import Type @@ -114,14 +116,20 @@ def iter_default_integrations(with_auto_enabling_integrations): def setup_integrations( - integrations, with_defaults=True, with_auto_enabling_integrations=False + integrations, + with_defaults=True, + with_auto_enabling_integrations=False, + disabled_integrations=None, ): - # type: (List[Integration], bool, bool) -> Dict[str, Integration] + # type: (Sequence[Integration], bool, bool, Optional[Sequence[Integration]]) -> Dict[str, Integration] """ Given a list of integration instances, this installs them all. When `with_defaults` is set to `True` all default integrations are added unless they were already provided before. + + `disabled_integrations` takes precedence over `with_defaults` and + `with_auto_enabling_integrations`. """ integrations = dict( (integration.identifier, integration) for integration in integrations or () @@ -129,6 +137,12 @@ def setup_integrations( logger.debug("Setting up integrations (with default = %s)", with_defaults) + # Integrations that will not be enabled + disabled_integrations = [ + integration if isinstance(integration, type) else type(integration) + for integration in disabled_integrations or [] + ] + # Integrations that are not explicitly set up by the user. used_as_default_integration = set() @@ -144,20 +158,23 @@ def setup_integrations( for identifier, integration in integrations.items(): with _installer_lock: if identifier not in _processed_integrations: - logger.debug( - "Setting up previously not enabled integration %s", identifier - ) - try: - type(integration).setup_once() - except DidNotEnable as e: - if identifier not in used_as_default_integration: - raise - + if type(integration) in disabled_integrations: + logger.debug("Ignoring integration %s", identifier) + else: logger.debug( - "Did not enable default integration %s: %s", identifier, e + "Setting up previously not enabled integration %s", identifier ) - else: - _installed_integrations.add(identifier) + try: + type(integration).setup_once() + except DidNotEnable as e: + if identifier not in used_as_default_integration: + raise + + logger.debug( + "Did not enable default integration %s: %s", identifier, e + ) + else: + _installed_integrations.add(identifier) _processed_integrations.add(identifier) diff --git a/sentry_sdk/integrations/celery/beat.py b/sentry_sdk/integrations/celery/beat.py index cedda5c467..6264d58804 100644 --- a/sentry_sdk/integrations/celery/beat.py +++ b/sentry_sdk/integrations/celery/beat.py @@ -228,13 +228,17 @@ def crons_task_success(sender, **kwargs): monitor_config = headers.get("sentry-monitor-config", {}) - start_timestamp_s = float(headers["sentry-monitor-start-timestamp-s"]) + start_timestamp_s = headers.get("sentry-monitor-start-timestamp-s") capture_checkin( monitor_slug=headers["sentry-monitor-slug"], monitor_config=monitor_config, check_in_id=headers["sentry-monitor-check-in-id"], - duration=_now_seconds_since_epoch() - start_timestamp_s, + duration=( + _now_seconds_since_epoch() - float(start_timestamp_s) + if start_timestamp_s + else None + ), status=MonitorStatus.OK, ) @@ -249,13 +253,17 @@ def crons_task_failure(sender, **kwargs): monitor_config = headers.get("sentry-monitor-config", {}) - start_timestamp_s = float(headers["sentry-monitor-start-timestamp-s"]) + start_timestamp_s = headers.get("sentry-monitor-start-timestamp-s") capture_checkin( monitor_slug=headers["sentry-monitor-slug"], monitor_config=monitor_config, check_in_id=headers["sentry-monitor-check-in-id"], - duration=_now_seconds_since_epoch() - start_timestamp_s, + duration=( + _now_seconds_since_epoch() - float(start_timestamp_s) + if start_timestamp_s + else None + ), status=MonitorStatus.ERROR, ) @@ -270,12 +278,16 @@ def crons_task_retry(sender, **kwargs): monitor_config = headers.get("sentry-monitor-config", {}) - start_timestamp_s = float(headers["sentry-monitor-start-timestamp-s"]) + start_timestamp_s = headers.get("sentry-monitor-start-timestamp-s") capture_checkin( monitor_slug=headers["sentry-monitor-slug"], monitor_config=monitor_config, check_in_id=headers["sentry-monitor-check-in-id"], - duration=_now_seconds_since_epoch() - start_timestamp_s, + duration=( + _now_seconds_since_epoch() - float(start_timestamp_s) + if start_timestamp_s + else None + ), status=MonitorStatus.ERROR, ) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 4f18d93a8a..253fce1745 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -116,6 +116,7 @@ class DjangoIntegration(Integration): identifier = "django" origin = f"auto.http.{identifier}" + origin_db = f"auto.db.{identifier}" transaction_style = "" middleware_spans = None @@ -630,7 +631,7 @@ def execute(self, sql, params=None): params_list=params, paramstyle="format", executemany=False, - span_origin=DjangoIntegration.origin, + span_origin=DjangoIntegration.origin_db, ) as span: _set_db_data(span, self) options = ( @@ -663,7 +664,7 @@ def executemany(self, sql, param_list): params_list=param_list, paramstyle="format", executemany=True, - span_origin=DjangoIntegration.origin, + span_origin=DjangoIntegration.origin_db, ) as span: _set_db_data(span, self) @@ -683,7 +684,7 @@ def connect(self): with sentry_sdk.start_span( op=OP.DB, description="connect", - origin=DjangoIntegration.origin, + origin=DjangoIntegration.origin_db, ) as span: _set_db_data(span, self) return real_connect(self) diff --git a/sentry_sdk/integrations/pymongo.py b/sentry_sdk/integrations/pymongo.py index 47fdfa6744..08d9cf84cd 100644 --- a/sentry_sdk/integrations/pymongo.py +++ b/sentry_sdk/integrations/pymongo.py @@ -163,8 +163,12 @@ def started(self, event): ) for tag, value in tags.items(): + # set the tag for backwards-compatibility. + # TODO: remove the set_tag call in the next major release! span.set_tag(tag, value) + span.set_data(tag, value) + for key, value in data.items(): span.set_data(key, value) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 5c16c60ff2..326dd37fd6 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -6,6 +6,7 @@ from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.scope import Scope, should_send_default_pii +from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT from sentry_sdk.utils import ( capture_internal_exceptions, ensure_integration_enabled, @@ -176,9 +177,9 @@ def on_operation(self): }, ) - scope = Scope.get_isolation_scope() - if scope.span: - self.graphql_span = scope.span.start_child( + span = sentry_sdk.get_current_span() + if span: + self.graphql_span = span.start_child( op=op, description=description, origin=StrawberryIntegration.origin, @@ -197,6 +198,12 @@ def on_operation(self): yield + transaction = self.graphql_span.containing_transaction + if transaction and self.execution_context.operation_name: + transaction.name = self.execution_context.operation_name + transaction.source = TRANSACTION_SOURCE_COMPONENT + transaction.op = op + self.graphql_span.finish() def on_validate(self): diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 117582ea2f..1b5c9c7c43 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -55,10 +55,14 @@ def get_request_url(environ, use_x_forwarded_for=False): # type: (Dict[str, str], bool) -> str """Return the absolute URL without query string for the given WSGI environment.""" + script_name = environ.get("SCRIPT_NAME", "").rstrip("/") + path_info = environ.get("PATH_INFO", "").lstrip("/") + path = f"{script_name}/{path_info}" + return "%s://%s/%s" % ( environ.get("wsgi.url_scheme"), get_host(environ, use_x_forwarded_for), - wsgi_decoding_dance(environ.get("PATH_INFO") or "").lstrip("/"), + wsgi_decoding_dance(path).lstrip("/"), ) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index b4274a4e7c..1febbd0ef2 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -987,7 +987,8 @@ def start_transaction( :param transaction: The transaction to start. If omitted, we create and start a new transaction. - :param instrumenter: This parameter is meant for internal use only. + :param instrumenter: This parameter is meant for internal use only. It + will be removed in the next major version. :param custom_sampling_context: The transaction's custom sampling context. :param kwargs: Optional keyword arguments to be passed to the Transaction constructor. See :py:class:`sentry_sdk.tracing.Transaction` for @@ -1031,9 +1032,8 @@ def start_transaction( transaction._profile = profile - # we don't bother to keep spans if we already know we're not going to - # send the transaction - if transaction.sampled: + # we don't bother to keep spans if we already know we're not going to + # send the transaction max_spans = (client.options["_experiments"].get("max_spans")) or 1000 transaction.init_span_recorder(maxlen=max_spans) @@ -1055,6 +1055,10 @@ def start_span(self, instrumenter=INSTRUMENTER.SENTRY, **kwargs): one is not already in progress. For supported `**kwargs` see :py:class:`sentry_sdk.tracing.Span`. + + The instrumenter parameter is deprecated for user code, and it will + be removed in the next major version. Going forward, it should only + be used by the SDK itself. """ with new_scope(): kwargs.setdefault("scope", self) @@ -1299,6 +1303,7 @@ def _apply_breadcrumbs_to_event(self, event, hint, options): event.setdefault("breadcrumbs", {}).setdefault("values", []).extend( self._breadcrumbs ) + event["breadcrumbs"]["values"].sort(key=lambda crumb: crumb["timestamp"]) def _apply_user_to_event(self, event, hint, options): # type: (Event, Hint, Optional[Dict[str, Any]]) -> None diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index f1f3200035..8e74707608 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -394,6 +394,10 @@ def start_child(self, instrumenter=INSTRUMENTER.SENTRY, **kwargs): Takes the same arguments as the initializer of :py:class:`Span`. The trace id, sampling decision, transaction pointer, and span recorder are inherited from the current span/transaction. + + The instrumenter parameter is deprecated for user code, and it will + be removed in the next major version. Going forward, it should only + be used by the SDK itself. """ configuration_instrumenter = sentry_sdk.Scope.get_client().options[ "instrumenter" @@ -802,7 +806,7 @@ def _possibly_started(self): def __enter__(self): # type: () -> Transaction if not self._possibly_started(): - logger.warning( + logger.debug( "Transaction was entered without being started with sentry_sdk.start_transaction." "The transaction will not be sent to Sentry. To fix, start the transaction by" "passing it to sentry_sdk.start_transaction." diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index ba20dc8436..4a50f50810 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -637,8 +637,8 @@ async def func_with_tracing(*args, **kwargs): span = get_current_span() if span is None: - logger.warning( - "Can not create a child span for %s. " + logger.debug( + "Cannot create a child span for %s. " "Please start a Sentry transaction before calling this function.", qualname_from_function(func), ) @@ -665,8 +665,8 @@ def func_with_tracing(*args, **kwargs): span = get_current_span() if span is None: - logger.warning( - "Can not create a child span for %s. " + logger.debug( + "Cannot create a child span for %s. " "Please start a Sentry transaction before calling this function.", qualname_from_function(func), ) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 2079be52cc..8a805d3d64 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -11,7 +11,6 @@ import threading import time from collections import namedtuple -from copy import copy from datetime import datetime from decimal import Decimal from functools import partial, partialmethod, wraps @@ -611,7 +610,7 @@ def serialize_frame( ) if include_local_variables: - rv["vars"] = copy(frame.f_locals) + rv["vars"] = frame.f_locals.copy() return rv @@ -1330,14 +1329,18 @@ def qualname_from_function(func): prefix, suffix = "", "" - if hasattr(func, "_partialmethod") and isinstance( - func._partialmethod, partialmethod - ): - prefix, suffix = "partialmethod()" - func = func._partialmethod.func - elif isinstance(func, partial) and hasattr(func.func, "__name__"): + if isinstance(func, partial) and hasattr(func.func, "__name__"): prefix, suffix = "partial()" func = func.func + else: + # The _partialmethod attribute of methods wrapped with partialmethod() was renamed to __partialmethod__ in CPython 3.13: + # https://github.com/python/cpython/pull/16600 + partial_method = getattr(func, "_partialmethod", None) or getattr( + func, "__partialmethod__", None + ) + if isinstance(partial_method, partialmethod): + prefix, suffix = "partialmethod()" + func = partial_method.func if hasattr(func, "__qualname__"): func_qualname = func.__qualname__ diff --git a/setup.py b/setup.py index f419737d36..0cea2dd51d 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.10.0", + version="2.11.0", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", diff --git a/tests/conftest.py b/tests/conftest.py index 048f8bc140..3c5e444f6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import json import os import socket +import warnings from threading import Thread from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, HTTPServer @@ -23,6 +24,7 @@ from sentry_sdk.envelope import Envelope from sentry_sdk.integrations import ( # noqa: F401 _DEFAULT_INTEGRATIONS, + _installed_integrations, _processed_integrations, ) from sentry_sdk.profiler import teardown_profiler @@ -181,6 +183,7 @@ def reset_integrations(): except ValueError: pass _processed_integrations.clear() + _installed_integrations.clear() @pytest.fixture @@ -561,6 +564,17 @@ def teardown_profiling(): teardown_continuous_profiler() +@pytest.fixture() +def suppress_deprecation_warnings(): + """ + Use this fixture to suppress deprecation warnings in a test. + Useful for testing deprecated SDK features. + """ + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + yield + + class MockServerRequestHandler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 # Process an HTTP GET request and return a response with an HTTP 200 status. diff --git a/tests/integrations/asyncpg/test_asyncpg.py b/tests/integrations/asyncpg/test_asyncpg.py index 94b02f4c32..e36d15c5d2 100644 --- a/tests/integrations/asyncpg/test_asyncpg.py +++ b/tests/integrations/asyncpg/test_asyncpg.py @@ -13,7 +13,7 @@ PG_HOST = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost") -PG_PORT = 5432 +PG_PORT = int(os.getenv("SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432")) PG_USER = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_USER", "postgres") PG_PASSWORD = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_PASSWORD", "sentry") PG_NAME = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_NAME", "postgres") diff --git a/tests/integrations/cloud_resource_context/test_cloud_resource_context.py b/tests/integrations/cloud_resource_context/test_cloud_resource_context.py index 90c78b28ec..49732b00a5 100644 --- a/tests/integrations/cloud_resource_context/test_cloud_resource_context.py +++ b/tests/integrations/cloud_resource_context/test_cloud_resource_context.py @@ -394,13 +394,17 @@ def test_setup_once( else: fake_set_context.assert_not_called() - if warning_called: - correct_warning_found = False + def invalid_value_warning_calls(): + """ + Iterator that yields True if the warning was called with the expected message. + Written as a generator function, rather than a list comprehension, to allow + us to handle exceptions that might be raised during the iteration if the + warning call was not as expected. + """ for call in fake_warning.call_args_list: - if call[0][0].startswith("Invalid value for cloud_provider:"): - correct_warning_found = True - break + try: + yield call[0][0].startswith("Invalid value for cloud_provider:") + except (IndexError, KeyError, TypeError, AttributeError): + ... - assert correct_warning_found - else: - fake_warning.assert_not_called() + assert warning_called == any(invalid_value_warning_calls()) diff --git a/tests/integrations/django/myapp/settings.py b/tests/integrations/django/myapp/settings.py index 8956357a51..0678762b6b 100644 --- a/tests/integrations/django/myapp/settings.py +++ b/tests/integrations/django/myapp/settings.py @@ -122,7 +122,7 @@ def middleware(request): DATABASES["postgres"] = { "ENGINE": db_engine, "HOST": os.environ.get("SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost"), - "PORT": 5432, + "PORT": int(os.environ.get("SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432")), "USER": os.environ.get("SENTRY_PYTHON_TEST_POSTGRES_USER", "postgres"), "PASSWORD": os.environ.get("SENTRY_PYTHON_TEST_POSTGRES_PASSWORD", "sentry"), "NAME": os.environ.get( diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index f79c6e13d5..1505204f28 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -626,7 +626,9 @@ def test_db_connection_span_data(sentry_init, client, capture_events): assert data.get(SPANDATA.SERVER_ADDRESS) == os.environ.get( "SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost" ) - assert data.get(SPANDATA.SERVER_PORT) == "5432" + assert data.get(SPANDATA.SERVER_PORT) == os.environ.get( + "SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432" + ) def test_set_db_data_custom_backend(): diff --git a/tests/integrations/django/test_db_query_data.py b/tests/integrations/django/test_db_query_data.py index 087fc5ad49..41ad9d5e1c 100644 --- a/tests/integrations/django/test_db_query_data.py +++ b/tests/integrations/django/test_db_query_data.py @@ -481,7 +481,10 @@ def test_db_span_origin_execute(sentry_init, client, capture_events): assert event["contexts"]["trace"]["origin"] == "auto.http.django" for span in event["spans"]: - assert span["origin"] == "auto.http.django" + if span["op"] == "db": + assert span["origin"] == "auto.db.django" + else: + assert span["origin"] == "auto.http.django" @pytest.mark.forked @@ -520,4 +523,4 @@ def test_db_span_origin_executemany(sentry_init, client, capture_events): (event,) = events assert event["contexts"]["trace"]["origin"] == "manual" - assert event["spans"][0]["origin"] == "auto.http.django" + assert event["spans"][0]["origin"] == "auto.db.django" diff --git a/tests/integrations/django/test_transactions.py b/tests/integrations/django/test_transactions.py index 67dbb78dfe..14f8170fc3 100644 --- a/tests/integrations/django/test_transactions.py +++ b/tests/integrations/django/test_transactions.py @@ -95,12 +95,35 @@ def test_resolver_path_multiple_groups(): django.VERSION < (2, 0), reason="Django>=2.0 required for patterns", ) +@pytest.mark.skipif( + django.VERSION > (5, 1), + reason="get_converter removed in 5.1", +) +def test_resolver_path_complex_path_legacy(): + class CustomPathConverter(PathConverter): + regex = r"[^/]+(/[^/]+){0,2}" + + with mock.patch( + "django.urls.resolvers.get_converter", + return_value=CustomPathConverter, + ): + url_conf = (path("api/v3/", lambda x: ""),) + resolver = RavenResolver() + result = resolver.resolve("/api/v3/abc/def/ghi", url_conf) + assert result == "/api/v3/{my_path}" + + +@pytest.mark.skipif( + django.VERSION < (5, 1), + reason="get_converters is used in 5.1", +) def test_resolver_path_complex_path(): class CustomPathConverter(PathConverter): regex = r"[^/]+(/[^/]+){0,2}" with mock.patch( - "django.urls.resolvers.get_converter", return_value=CustomPathConverter + "django.urls.resolvers.get_converters", + return_value={"custom_path": CustomPathConverter}, ): url_conf = (path("api/v3/", lambda x: ""),) resolver = RavenResolver() diff --git a/tests/integrations/pymongo/test_pymongo.py b/tests/integrations/pymongo/test_pymongo.py index 172668619b..80fe40fdcf 100644 --- a/tests/integrations/pymongo/test_pymongo.py +++ b/tests/integrations/pymongo/test_pymongo.py @@ -62,21 +62,28 @@ def test_transactions(sentry_init, capture_events, mongo_server, with_pii): assert span["data"][SPANDATA.SERVER_PORT] == mongo_server.port for field, value in common_tags.items(): assert span["tags"][field] == value + assert span["data"][field] == value assert find["op"] == "db" assert insert_success["op"] == "db" assert insert_fail["op"] == "db" + assert find["data"]["db.operation"] == "find" assert find["tags"]["db.operation"] == "find" + assert insert_success["data"]["db.operation"] == "insert" assert insert_success["tags"]["db.operation"] == "insert" + assert insert_fail["data"]["db.operation"] == "insert" assert insert_fail["tags"]["db.operation"] == "insert" assert find["description"].startswith('{"find') assert insert_success["description"].startswith('{"insert') assert insert_fail["description"].startswith('{"insert') + assert find["data"][SPANDATA.DB_MONGODB_COLLECTION] == "test_collection" assert find["tags"][SPANDATA.DB_MONGODB_COLLECTION] == "test_collection" + assert insert_success["data"][SPANDATA.DB_MONGODB_COLLECTION] == "test_collection" assert insert_success["tags"][SPANDATA.DB_MONGODB_COLLECTION] == "test_collection" + assert insert_fail["data"][SPANDATA.DB_MONGODB_COLLECTION] == "erroneous" assert insert_fail["tags"][SPANDATA.DB_MONGODB_COLLECTION] == "erroneous" if with_pii: assert "1" in find["description"] diff --git a/tests/integrations/strawberry/test_strawberry.py b/tests/integrations/strawberry/test_strawberry.py index fc6f31710e..dcc6632bdb 100644 --- a/tests/integrations/strawberry/test_strawberry.py +++ b/tests/integrations/strawberry/test_strawberry.py @@ -324,11 +324,8 @@ def test_capture_transaction_on_error( assert len(events) == 2 (_, transaction_event) = events - if async_execution: - assert transaction_event["transaction"] == "/graphql" - else: - assert transaction_event["transaction"] == "graphql_view" - + assert transaction_event["transaction"] == "ErrorQuery" + assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_QUERY assert transaction_event["spans"] query_spans = [ @@ -404,11 +401,8 @@ def test_capture_transaction_on_success( assert len(events) == 1 (transaction_event,) = events - if async_execution: - assert transaction_event["transaction"] == "/graphql" - else: - assert transaction_event["transaction"] == "graphql_view" - + assert transaction_event["transaction"] == "GreetingQuery" + assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_QUERY assert transaction_event["spans"] query_spans = [ @@ -564,11 +558,8 @@ def test_transaction_mutation( assert len(events) == 1 (transaction_event,) = events - if async_execution: - assert transaction_event["transaction"] == "/graphql" - else: - assert transaction_event["transaction"] == "graphql_view" - + assert transaction_event["transaction"] == "Change" + assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_MUTATION assert transaction_event["spans"] query_spans = [ diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index d2fa6f2135..656fc1757f 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -61,6 +61,25 @@ def test_basic(sentry_init, crashing_app, capture_events): } +@pytest.mark.parametrize("path_info", ("bark/", "/bark/")) +@pytest.mark.parametrize("script_name", ("woof/woof", "woof/woof/")) +def test_script_name_is_respected( + sentry_init, crashing_app, capture_events, script_name, path_info +): + sentry_init(send_default_pii=True) + app = SentryWsgiMiddleware(crashing_app) + client = Client(app) + events = capture_events() + + with pytest.raises(ZeroDivisionError): + # setting url with PATH_INFO: bark/, HTTP_HOST: dogs.are.great and SCRIPT_NAME: woof/woof/ + client.get(path_info, f"https://dogs.are.great/{script_name}") # noqa: E231 + + (event,) = events + + assert event["request"]["url"] == "https://dogs.are.great/woof/woof/bark/" + + @pytest.fixture(params=[0, None]) def test_systemexit_zero_is_ignored(sentry_init, capture_events, request): zero_code = request.param diff --git a/tests/new_scopes_compat/conftest.py b/tests/new_scopes_compat/conftest.py index 3afcf91704..9f16898dea 100644 --- a/tests/new_scopes_compat/conftest.py +++ b/tests/new_scopes_compat/conftest.py @@ -3,6 +3,6 @@ @pytest.fixture(autouse=True) -def isolate_hub(): +def isolate_hub(suppress_deprecation_warnings): with sentry_sdk.Hub(None): yield diff --git a/tests/new_scopes_compat/test_new_scopes_compat_event.py b/tests/new_scopes_compat/test_new_scopes_compat_event.py index fd43a25c69..db1e5fec4b 100644 --- a/tests/new_scopes_compat/test_new_scopes_compat_event.py +++ b/tests/new_scopes_compat/test_new_scopes_compat_event.py @@ -4,6 +4,7 @@ import sentry_sdk from sentry_sdk.hub import Hub +from sentry_sdk.integrations import iter_default_integrations from sentry_sdk.scrubber import EventScrubber, DEFAULT_DENYLIST @@ -18,7 +19,17 @@ @pytest.fixture -def expected_error(): +def integrations(): + return [ + integration.identifier + for integration in iter_default_integrations( + with_auto_enabling_integrations=False + ) + ] + + +@pytest.fixture +def expected_error(integrations): def create_expected_error_event(trx, span): return { "level": "warning-X", @@ -122,16 +133,7 @@ def create_expected_error_event(trx, span): "name": "sentry.python", "version": mock.ANY, "packages": [{"name": "pypi:sentry-sdk", "version": mock.ANY}], - "integrations": [ - "argv", - "atexit", - "dedupe", - "excepthook", - "logging", - "modules", - "stdlib", - "threading", - ], + "integrations": integrations, }, "platform": "python", "_meta": { @@ -149,7 +151,7 @@ def create_expected_error_event(trx, span): @pytest.fixture -def expected_transaction(): +def expected_transaction(integrations): def create_expected_transaction_event(trx, span): return { "type": "transaction", @@ -220,16 +222,7 @@ def create_expected_transaction_event(trx, span): "name": "sentry.python", "version": mock.ANY, "packages": [{"name": "pypi:sentry-sdk", "version": mock.ANY}], - "integrations": [ - "argv", - "atexit", - "dedupe", - "excepthook", - "logging", - "modules", - "stdlib", - "threading", - ], + "integrations": integrations, }, "platform": "python", "_meta": { @@ -328,6 +321,7 @@ def _init_sentry_sdk(sentry_init): ), send_default_pii=False, traces_sample_rate=1.0, + auto_enabling_integrations=False, ) diff --git a/tests/profiler/test_transaction_profiler.py b/tests/profiler/test_transaction_profiler.py index d657bec506..142fd7d78c 100644 --- a/tests/profiler/test_transaction_profiler.py +++ b/tests/profiler/test_transaction_profiler.py @@ -817,7 +817,7 @@ def test_profile_processing( assert processed["samples"] == expected["samples"] -def test_hub_backwards_compatibility(): +def test_hub_backwards_compatibility(suppress_deprecation_warnings): hub = sentry_sdk.Hub() with pytest.warns(DeprecationWarning): diff --git a/tests/test_ai_monitoring.py b/tests/test_ai_monitoring.py new file mode 100644 index 0000000000..4329cc92af --- /dev/null +++ b/tests/test_ai_monitoring.py @@ -0,0 +1,59 @@ +import sentry_sdk +from sentry_sdk.ai.monitoring import ai_track + + +def test_ai_track(sentry_init, capture_events): + sentry_init(traces_sample_rate=1.0) + events = capture_events() + + @ai_track("my tool") + def tool(**kwargs): + pass + + @ai_track("some test pipeline") + def pipeline(): + tool() + + with sentry_sdk.start_transaction(): + pipeline() + + transaction = events[0] + assert transaction["type"] == "transaction" + assert len(transaction["spans"]) == 2 + spans = transaction["spans"] + + ai_pipeline_span = spans[0] if spans[0]["op"] == "ai.pipeline" else spans[1] + ai_run_span = spans[0] if spans[0]["op"] == "ai.run" else spans[1] + + assert ai_pipeline_span["description"] == "some test pipeline" + assert ai_run_span["description"] == "my tool" + + +def test_ai_track_with_tags(sentry_init, capture_events): + sentry_init(traces_sample_rate=1.0) + events = capture_events() + + @ai_track("my tool") + def tool(**kwargs): + pass + + @ai_track("some test pipeline") + def pipeline(): + tool() + + with sentry_sdk.start_transaction(): + pipeline(sentry_tags={"user": "colin"}, sentry_data={"some_data": "value"}) + + transaction = events[0] + assert transaction["type"] == "transaction" + assert len(transaction["spans"]) == 2 + spans = transaction["spans"] + + ai_pipeline_span = spans[0] if spans[0]["op"] == "ai.pipeline" else spans[1] + ai_run_span = spans[0] if spans[0]["op"] == "ai.run" else spans[1] + + assert ai_pipeline_span["description"] == "some test pipeline" + print(ai_pipeline_span) + assert ai_pipeline_span["tags"]["user"] == "colin" + assert ai_pipeline_span["data"]["some_data"] == "value" + assert ai_run_span["description"] == "my tool" diff --git a/tests/test_basics.py b/tests/test_basics.py index 439215e013..3a801c5785 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -1,3 +1,5 @@ +import datetime +import importlib import logging import os import sys @@ -6,12 +8,12 @@ import pytest from sentry_sdk.client import Client - from tests.conftest import patch_start_tracing_child import sentry_sdk import sentry_sdk.scope from sentry_sdk import ( + get_client, push_scope, configure_scope, capture_event, @@ -26,11 +28,13 @@ ) from sentry_sdk.integrations import ( _AUTO_ENABLING_INTEGRATIONS, + _DEFAULT_INTEGRATIONS, Integration, 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 from sentry_sdk.tracing_utils import has_tracing_enabled @@ -391,6 +395,37 @@ def test_breadcrumbs(sentry_init, capture_events): assert len(event["breadcrumbs"]["values"]) == 0 +def test_breadcrumb_ordering(sentry_init, capture_events): + sentry_init() + events = capture_events() + + timestamps = [ + datetime.datetime.now() - datetime.timedelta(days=10), + datetime.datetime.now() - datetime.timedelta(days=8), + datetime.datetime.now() - datetime.timedelta(days=12), + ] + + for timestamp in timestamps: + add_breadcrumb( + message="Authenticated at %s" % timestamp, + category="auth", + level="info", + timestamp=timestamp, + ) + + capture_exception(ValueError()) + (event,) = events + + assert len(event["breadcrumbs"]["values"]) == len(timestamps) + timestamps_from_event = [ + datetime.datetime.strptime( + x["timestamp"].replace("Z", ""), "%Y-%m-%dT%H:%M:%S.%f" + ) + for x in event["breadcrumbs"]["values"] + ] + assert timestamps_from_event == sorted(timestamps) + + def test_attachments(sentry_init, capture_envelopes): sentry_init() envelopes = capture_envelopes() @@ -441,6 +476,51 @@ def test_integration_scoping(sentry_init, capture_events): assert not events +default_integrations = [ + getattr( + importlib.import_module(integration.rsplit(".", 1)[0]), + integration.rsplit(".", 1)[1], + ) + for integration in _DEFAULT_INTEGRATIONS +] + + +@pytest.mark.forked +@pytest.mark.parametrize( + "provided_integrations,default_integrations,disabled_integrations,expected_integrations", + [ + ([], False, None, set()), + ([], False, [], set()), + ([LoggingIntegration()], False, None, {LoggingIntegration}), + ([], True, None, set(default_integrations)), + ( + [], + True, + [LoggingIntegration(), StdlibIntegration], + set(default_integrations) - {LoggingIntegration, StdlibIntegration}, + ), + ], +) +def test_integrations( + sentry_init, + provided_integrations, + default_integrations, + disabled_integrations, + expected_integrations, + reset_integrations, +): + sentry_init( + integrations=provided_integrations, + default_integrations=default_integrations, + disabled_integrations=disabled_integrations, + auto_enabling_integrations=False, + debug=True, + ) + assert { + type(integration) for integration in get_client().integrations.values() + } == expected_integrations + + @pytest.mark.skip( reason="This test is not valid anymore, because with the new Scopes calling bind_client on the Hub sets the client on the global scope. This test should be removed once the Hub is removed" ) @@ -839,3 +919,21 @@ def test_last_event_id_scope(sentry_init): # Should not crash with isolation_scope() as scope: assert scope.last_event_id() is None + + +def test_hub_constructor_deprecation_warning(): + with pytest.warns(sentry_sdk.hub.SentryHubDeprecationWarning): + Hub() + + +def test_hub_current_deprecation_warning(): + with pytest.warns(sentry_sdk.hub.SentryHubDeprecationWarning) as warning_records: + Hub.current + + # Make sure we only issue one deprecation warning + assert len(warning_records) == 1 + + +def test_hub_main_deprecation_warnings(): + with pytest.warns(sentry_sdk.hub.SentryHubDeprecationWarning): + Hub.main diff --git a/tests/test_client.py b/tests/test_client.py index 3be8b1e64b..571912ab12 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -33,6 +33,12 @@ from sentry_sdk._types import Event +maximum_python_312 = pytest.mark.skipif( + sys.version_info > (3, 12), + reason="Since Python 3.13, `FrameLocalsProxy` skips items of `locals()` that have non-`str` keys; this is a CPython implementation detail: https://github.com/python/cpython/blame/7b413952e817ae87bfda2ac85dd84d30a6ce743b/Objects/frameobject.c#L148", +) + + class EnvelopeCapturedError(Exception): pass @@ -889,6 +895,7 @@ class FooError(Exception): assert exception["mechanism"]["meta"]["errno"]["number"] == 69 +@maximum_python_312 def test_non_string_variables(sentry_init, capture_events): """There is some extremely terrible code in the wild that inserts non-strings as variable names into `locals()`.""" diff --git a/tests/test_utils.py b/tests/test_utils.py index c4064729f8..40a3296564 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -26,6 +26,7 @@ serialize_frame, is_sentry_url, _get_installed_modules, + _generate_installed_modules, ensure_integration_enabled, ensure_integration_enabled_async, ) @@ -523,7 +524,7 @@ def test_installed_modules(): installed_distributions = { _normalize_distribution_name(dist): version - for dist, version in _get_installed_modules().items() + for dist, version in _generate_installed_modules() } if importlib_available: diff --git a/tests/tracing/test_decorator.py b/tests/tracing/test_decorator.py index 6c2d337285..584268fbdd 100644 --- a/tests/tracing/test_decorator.py +++ b/tests/tracing/test_decorator.py @@ -33,14 +33,14 @@ def test_trace_decorator(): def test_trace_decorator_no_trx(): with patch_start_tracing_child(fake_transaction_is_none=True): - with mock.patch.object(logger, "warning", mock.Mock()) as fake_warning: + with mock.patch.object(logger, "debug", mock.Mock()) as fake_debug: result = my_example_function() - fake_warning.assert_not_called() + fake_debug.assert_not_called() assert result == "return_of_sync_function" result2 = start_child_span_decorator(my_example_function)() - fake_warning.assert_called_once_with( - "Can not create a child span for %s. " + fake_debug.assert_called_once_with( + "Cannot create a child span for %s. " "Please start a Sentry transaction before calling this function.", "test_decorator.my_example_function", ) @@ -66,14 +66,14 @@ async def test_trace_decorator_async(): @pytest.mark.asyncio async def test_trace_decorator_async_no_trx(): with patch_start_tracing_child(fake_transaction_is_none=True): - with mock.patch.object(logger, "warning", mock.Mock()) as fake_warning: + with mock.patch.object(logger, "debug", mock.Mock()) as fake_debug: result = await my_async_example_function() - fake_warning.assert_not_called() + fake_debug.assert_not_called() assert result == "return_of_async_function" result2 = await start_child_span_decorator(my_async_example_function)() - fake_warning.assert_called_once_with( - "Can not create a child span for %s. " + fake_debug.assert_called_once_with( + "Cannot create a child span for %s. " "Please start a Sentry transaction before calling this function.", "test_decorator.my_async_example_function", ) diff --git a/tests/tracing/test_deprecated.py b/tests/tracing/test_deprecated.py index 8b7f34b6cb..fb58e43ebf 100644 --- a/tests/tracing/test_deprecated.py +++ b/tests/tracing/test_deprecated.py @@ -27,17 +27,29 @@ def test_start_span_to_start_transaction(sentry_init, capture_events): assert events[1]["transaction"] == "/2/" -@pytest.mark.parametrize("parameter_value", (sentry_sdk.Hub(), sentry_sdk.Scope())) -def test_passing_hub_parameter_to_transaction_finish(parameter_value): +@pytest.mark.parametrize( + "parameter_value_getter", + # Use lambda to avoid Hub deprecation warning here (will suppress it in the test) + (lambda: sentry_sdk.Hub(), lambda: sentry_sdk.Scope()), +) +def test_passing_hub_parameter_to_transaction_finish( + suppress_deprecation_warnings, parameter_value_getter +): + parameter_value = parameter_value_getter() transaction = sentry_sdk.tracing.Transaction() with pytest.warns(DeprecationWarning): transaction.finish(hub=parameter_value) -def test_passing_hub_object_to_scope_transaction_finish(): +def test_passing_hub_object_to_scope_transaction_finish(suppress_deprecation_warnings): transaction = sentry_sdk.tracing.Transaction() + + # Do not move the following line under the `with` statement. Otherwise, the Hub.__init__ deprecation + # warning will be confused with the transaction.finish deprecation warning that we are testing. + hub = sentry_sdk.Hub() + with pytest.warns(DeprecationWarning): - transaction.finish(sentry_sdk.Hub()) + transaction.finish(hub) def test_no_warnings_scope_to_transaction_finish(): diff --git a/tests/tracing/test_misc.py b/tests/tracing/test_misc.py index 6d722e992f..fcfcf31b69 100644 --- a/tests/tracing/test_misc.py +++ b/tests/tracing/test_misc.py @@ -412,7 +412,7 @@ def test_transaction_not_started_warning(sentry_init): with tx: pass - mock_logger.warning.assert_any_call( + mock_logger.debug.assert_any_call( "Transaction was entered without being started with sentry_sdk.start_transaction." "The transaction will not be sent to Sentry. To fix, start the transaction by" "passing it to sentry_sdk.start_transaction." diff --git a/tests/tracing/test_noop_span.py b/tests/tracing/test_noop_span.py index 59f8cae489..c9aad60590 100644 --- a/tests/tracing/test_noop_span.py +++ b/tests/tracing/test_noop_span.py @@ -1,9 +1,9 @@ import sentry_sdk from sentry_sdk.tracing import NoOpSpan -# This tests make sure, that the examples from the documentation [1] -# are working when OTel (OpenTelementry) instrumentation is turned on -# and therefore the Senntry tracing should not do anything. +# These tests make sure that the examples from the documentation [1] +# are working when OTel (OpenTelemetry) instrumentation is turned on, +# and therefore, the Sentry tracing should not do anything. # # 1: https://docs.sentry.io/platforms/python/performance/instrumentation/custom-instrumentation/ diff --git a/tox.ini b/tox.ini index 216b9c6e5a..3ab1bae529 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ requires = virtualenv<20.26.3 envlist = # === Common === - {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-common + {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-common # === Gevent === {py3.6,py3.8,py3.10,py3.11,py3.12}-gevent @@ -105,7 +105,7 @@ envlist = # - Django 4.x {py3.8,py3.11,py3.12}-django-v{4.0,4.1,4.2} # - Django 5.x - {py3.10,py3.11,py3.12}-django-v{5.0} + {py3.10,py3.11,py3.12}-django-v{5.0,5.1} {py3.10,py3.11,py3.12}-django-latest # Falcon @@ -271,11 +271,12 @@ deps = # === Common === py3.8-common: hypothesis - {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-common: pytest-asyncio + {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-common: pytest-asyncio # See https://github.com/pytest-dev/pytest/issues/9621 # and https://github.com/pytest-dev/pytest-forked/issues/67 # for justification of the upper bound on pytest {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-common: pytest<7.0.0 + py3.13-common: pytest # === Gevent === {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-gevent: gevent>=22.10.0, <22.11.0 @@ -373,13 +374,13 @@ deps = # Django django: psycopg2-binary django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: djangorestframework>=3.0.0,<4.0.0 - django-v{2.0,2.2,3.0,3.2,4.0,4.1,4.2,5.0}: channels[daphne] + django-v{2.0,2.2,3.0,3.2,4.0,4.1,4.2,5.0,5.1}: channels[daphne] django-v{1.11,2.0,2.2,3.0,3.2}: Werkzeug<2.1.0 django-v{1.11,2.0,2.2,3.0}: pytest-django<4.0 - django-v{3.2,4.0,4.1,4.2,5.0}: pytest-django - django-v{4.0,4.1,4.2,5.0}: djangorestframework - django-v{4.0,4.1,4.2,5.0}: pytest-asyncio - django-v{4.0,4.1,4.2,5.0}: Werkzeug + django-v{3.2,4.0,4.1,4.2,5.0,5.1}: pytest-django + django-v{4.0,4.1,4.2,5.0,5.1}: djangorestframework + django-v{4.0,4.1,4.2,5.0,5.1}: pytest-asyncio + django-v{4.0,4.1,4.2,5.0,5.1}: Werkzeug django-latest: djangorestframework django-latest: pytest-asyncio django-latest: pytest-django @@ -395,6 +396,7 @@ deps = django-v4.1: Django~=4.1.0 django-v4.2: Django~=4.2.0 django-v5.0: Django~=5.0.0 + django-v5.1: Django==5.1b1 django-latest: Django # Falcon @@ -739,7 +741,7 @@ commands = ; Running `pytest` as an executable suffers from an import error ; when loading tests in scenarios. In particular, django fails to ; load the settings from the test module. - python -m pytest {env:TESTPATH} {posargs} + python -m pytest {env:TESTPATH} -o junit_suite_name={envname} {posargs} [testenv:linters] commands =