Skip to content

Commit e2699d9

Browse files
fix: Report each failed attempt once and tag the final attempt (#46)
* fix: Report each failed attempt once and tag the final attempt Co-Authored-By: Joshua Li <14209781+joshuarli@users.noreply.github.com> * build: Port to uv, Ruff and ty; require Python 3.13+ - Replace setup.py/setup.cfg/dev-requirements/flake8/Makefile with a uv-managed pyproject.toml (uv_build backend) and a committed uv.lock. - Require Python >=3.13; CI tests 3.13 and 3.14. - Upgrade dependencies to latest: pytest 9.1.1, sentry-sdk 3.0.0a7, wrapt 2.5.1, pytest-rerunfailures 16.7, ruff 0.17.1, ty 0.0.90. - Pin OpenTelemetry <1.44 for dev/CI: sentry-sdk 3.0.0a7 crashes at import with newer OpenTelemetry (Context.pop on an immutable Context), which is what broke every test job on this PR. - Ruff with select=ALL + preview, ty with all rules as errors; fix all findings (types, docstrings, imports) without blanket suppressions. - CI: lint/format/type-check job, uv-based test matrix, uv build for dist (artifact name unchanged for craft); pin actions to SHAs. - bump-version.sh updates pyproject.toml and uv.lock. Co-Authored-By: Joshua Li <14209781+joshuarli@users.noreply.github.com> * fix: Decide pytest.final_attempt from rerunfailures' actual retry decision Comparing execution_count to the rerun count missed attempts that pytest-rerunfailures does not retry for other reasons (--only-rerun, --rerun-except, a false condition, --max-suite-reruns), tagging hard failures as final_attempt:false. Collect the attempt's exceptions in pytest_runtest_makereport and capture them in pytest_runtest_logfinish, after rerunfailures has marked a retried attempt's reports as 'rerun'. An attempt with no 'rerun' report is final. * fix(release): Reject versions that could inject sed commands in bump-version.sh --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Joshua Li <14209781+joshuarli@users.noreply.github.com>
1 parent b5f801d commit e2699d9

27 files changed

Lines changed: 1234 additions & 457 deletions

‎.flake8‎

Lines changed: 0 additions & 6 deletions
This file was deleted.

‎.github/workflows/main.yml‎

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,47 @@ on:
33
push:
44
branches: [main, release/**, test-me-*]
55

6+
permissions:
7+
contents: read
8+
69
jobs:
10+
lint:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
14+
with:
15+
persist-credentials: false
16+
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
17+
- run: uv sync --locked
18+
- run: uv run --locked ruff check --output-format=github .
19+
- run: uv run --locked ruff format --check .
20+
- run: uv run --locked ty check --output-format=github
721
test:
822
runs-on: ubuntu-latest
923
strategy:
24+
fail-fast: false
1025
matrix:
11-
python: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
26+
python: ['3.13', '3.14']
1227
steps:
13-
- uses: actions/checkout@v3
14-
- uses: actions/setup-python@v4
28+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29+
with:
30+
persist-credentials: false
31+
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
1532
with:
1633
python-version: ${{ matrix.python }}
17-
- run: pip install -r dev-requirements.txt
18-
- run: PYTEST_SENTRY_ALWAYS_REPORT=1 pytest tests
34+
- run: uv sync --locked
35+
- run: uv run --locked pytest tests
36+
env:
37+
PYTEST_SENTRY_ALWAYS_REPORT: '1'
1938
dist:
2039
runs-on: ubuntu-latest
2140
steps:
22-
- uses: actions/checkout@v3
23-
- uses: actions/setup-python@v4
41+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
2442
with:
25-
python-version: '3.x'
26-
- run: |
27-
pip install build
28-
python -m build
29-
- uses: actions/upload-artifact@v4
43+
persist-credentials: false
44+
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
45+
- run: uv build
46+
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
3047
with:
3148
name: ${{ github.sha }}
3249
path: dist/*

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
with:
2222
app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
2323
private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
24-
- uses: actions/checkout@v3
24+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
2525
with:
2626
token: ${{ steps.token.outputs.token }}
2727
fetch-depth: 0

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ venv
2020
.pytest_cache
2121
.hypothesis
2222
semaphore
23+
.ruff_cache

‎Makefile‎

Lines changed: 0 additions & 4 deletions
This file was deleted.

‎README.md‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This is what a test run will look like in Sentry.
1515
## Quickstart
1616
### Prerequisites
1717

18+
* Python 3.13 or newer.
1819
* You are using [pytest](https://pytest.org) to run your tests.
1920
* You are using [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) to rerun flaky tests.
2021

@@ -70,6 +71,12 @@ Now every test failure that is "fixed" by retrying the test is reported to
7071
Sentry, but still does not break CI. Tests that consistently fail will not be
7172
reported.
7273

74+
With `PYTEST_SENTRY_ALWAYS_REPORT=1`, every failed attempt is reported instead,
75+
each one exactly once. Error events are tagged with `pytest.attempt` (1-based)
76+
and `pytest.final_attempt` (`true` once `pytest-rerunfailures` will not retry
77+
the test again), so you can tell flaky retries apart from tests that failed
78+
every attempt, e.g. alert on `pytest.result:failed pytest.final_attempt:true`.
79+
7380
# Tracking the performance of your testsuite
7481

7582
By default `pytest-sentry` will send [Performance](https://sentry.io/for/performance/) data to Sentry:
@@ -157,9 +164,9 @@ the isolation `Scope` being used to send events to Sentry. Use `with use_isolati
157164
to temporarily switch context. You can use this to set custom tags like so::
158165

159166
```python
160-
def test_foo(sentry_test_scope):
161-
with use_isolation_scope(sentry_test_scope):
162-
sentry_sdk.set_tag("pull_request", os.environ['EXAMPLE_CI_PULL_REQUEST'])
167+
def test_foo(sentry_test_scope):
168+
with use_isolation_scope(sentry_test_scope):
169+
sentry_sdk.set_tag("pull_request", os.environ["EXAMPLE_CI_PULL_REQUEST"])
163170
```
164171

165172
Why all the hassle with the context manager? Just imagine if your tested
@@ -175,6 +182,20 @@ This can be enabled for builds on the `main` or release branch, to catch
175182
certain kinds of tests that are flaky across builds, but consistently fail or
176183
pass within one testrun.
177184

185+
# Development
186+
187+
The project is managed with [uv](https://docs.astral.sh/uv/), linted and
188+
formatted with [Ruff](https://docs.astral.sh/ruff/) and type-checked with
189+
[ty](https://docs.astral.sh/ty/):
190+
191+
```bash
192+
uv sync
193+
uv run ruff check .
194+
uv run ruff format --check .
195+
uv run ty check
196+
PYTEST_SENTRY_ALWAYS_REPORT=1 uv run pytest tests
197+
```
198+
178199
# License
179200

180201
Licensed under 2-clause BSD, see [LICENSE](LICENSE).

‎dev-requirements.txt‎

Lines changed: 0 additions & 3 deletions
This file was deleted.

‎pyproject.toml‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
[project]
2+
name = "pytest-sentry"
3+
version = "0.5.1"
4+
description = "A pytest plugin to send testrun information to Sentry.io"
5+
readme = "README.md"
6+
license = "BSD-2-Clause"
7+
license-files = ["LICENSE"]
8+
authors = [{ name = "Markus Unterwaditzer", email = "markus@unterwaditzer.net" }]
9+
requires-python = ">=3.13"
10+
classifiers = [
11+
"Framework :: Pytest",
12+
"Programming Language :: Python :: 3",
13+
"Programming Language :: Python :: 3 :: Only",
14+
"Programming Language :: Python :: 3.13",
15+
"Programming Language :: Python :: 3.14",
16+
]
17+
dependencies = [
18+
"pytest>=9.1.1",
19+
"sentry-sdk>=3.0.0a7",
20+
"wrapt>=2.5.1",
21+
]
22+
23+
[project.urls]
24+
Repository = "https://github.com/getsentry/pytest-sentry"
25+
26+
[project.entry-points.pytest11]
27+
sentry = "pytest_sentry"
28+
29+
[dependency-groups]
30+
dev = [
31+
"pytest-rerunfailures>=16.7",
32+
"ruff>=0.17.1",
33+
"ty>=0.0.90",
34+
]
35+
36+
[build-system]
37+
requires = ["uv_build>=0.13.4,<0.14"]
38+
build-backend = "uv_build"
39+
40+
[tool.uv]
41+
# sentry-sdk 3.0.0a7 crashes on import with opentelemetry >= 1.44, whose
42+
# Context became immutable (sentry_sdk/opentelemetry/contextvars_context.py
43+
# calls Context.pop). Dev/CI-only constraint; not part of the package metadata.
44+
constraint-dependencies = ["opentelemetry-api<1.44", "opentelemetry-sdk<1.44"]
45+
46+
[tool.uv.build-backend]
47+
module-root = ""
48+
49+
[tool.ruff]
50+
target-version = "py313"
51+
line-length = 100
52+
53+
[tool.ruff.lint]
54+
select = ["ALL"]
55+
preview = true
56+
explicit-preview-rules = false
57+
ignore = [
58+
# Conflict with the formatter: https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
59+
"missing-trailing-comma",
60+
"single-line-implicit-string-concatenation",
61+
# Mutually exclusive with D211 / D212, which are enforced instead.
62+
"incorrect-blank-line-before-class",
63+
"multi-line-summary-second-line",
64+
]
65+
66+
[tool.ruff.lint.per-file-ignores]
67+
"tests/**" = [
68+
"assert", # pytest uses bare asserts
69+
"magic-value-comparison", # magic values in assertions
70+
]
71+
72+
[tool.ruff.format]
73+
preview = true
74+
75+
[tool.ty.environment]
76+
python-version = "3.13"
77+
78+
[tool.ty.rules]
79+
all = "error"
80+
81+
[tool.ty.terminal]
82+
error-on-warning = true
83+
84+
[tool.pytest.ini_options]
85+
addopts = ["-p", "no:cacheprovider", "-p", "pytester"]

‎pytest_sentry/__init__.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
1-
from .client import Client # noqa: F401
2-
from .fixtures import sentry_test_scope # noqa: F401
3-
from .hooks import ( # noqa: F401
1+
# Copyright (c) 2018 Markus Unterwaditzer and individual contributors.
2+
"""A pytest plugin that reports test runs to Sentry."""
3+
4+
from pytest_sentry.client import Client
5+
from pytest_sentry.fixtures import sentry_test_scope
6+
from pytest_sentry.hooks import (
47
pytest_fixture_setup,
58
pytest_load_initial_conftests,
69
pytest_runtest_call,
10+
pytest_runtest_logfinish,
711
pytest_runtest_makereport,
812
pytest_runtest_protocol,
913
)
14+
from pytest_sentry.integration import PytestIntegration
15+
16+
__all__ = [
17+
"Client",
18+
"PytestIntegration",
19+
"pytest_fixture_setup",
20+
"pytest_load_initial_conftests",
21+
"pytest_runtest_call",
22+
"pytest_runtest_logfinish",
23+
"pytest_runtest_makereport",
24+
"pytest_runtest_protocol",
25+
"sentry_test_scope",
26+
]

‎pytest_sentry/client.py‎

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,38 @@
1+
# Copyright (c) 2018 Markus Unterwaditzer and individual contributors.
2+
"""The Sentry client used to report tests."""
3+
14
import os
5+
from typing import Any, cast
6+
27
import sentry_sdk
38

4-
from .integration import PytestIntegration
9+
from pytest_sentry.integration import PytestIntegration
10+
11+
_TRUTHY = frozenset({"1", "true", "yes"})
512

613

714
class Client(sentry_sdk.Client):
8-
"""
9-
A client that is used to report errors from tests.
10-
Configured via `PYTEST_SENTRY_*` environment variables.
15+
"""A client that reports errors from tests.
16+
17+
Configured via `PYTEST_SENTRY_*` environment variables. Explicitly passed
18+
options take precedence over the environment.
1119
"""
1220

13-
def __init__(self, *args, **kwargs):
14-
kwargs.setdefault(
15-
"dsn",
16-
os.environ.get("PYTEST_SENTRY_DSN", None),
17-
)
18-
kwargs.setdefault(
19-
"traces_sample_rate",
20-
float(os.environ.get("PYTEST_SENTRY_TRACES_SAMPLE_RATE", 1.0)),
21-
)
22-
kwargs.setdefault(
23-
"profiles_sample_rate",
24-
float(os.environ.get("PYTEST_SENTRY_PROFILES_SAMPLE_RATE", 0.0)),
25-
)
26-
kwargs.setdefault(
27-
"_experiments",
28-
{},
29-
).setdefault(
30-
"auto_enabling_integrations",
31-
True,
32-
)
33-
kwargs.setdefault(
34-
"environment",
35-
os.environ.get("SENTRY_ENVIRONMENT", "test"),
36-
)
37-
kwargs.setdefault("integrations", []).append(PytestIntegration())
38-
39-
debug = os.environ.get("PYTEST_SENTRY_DEBUG", "").lower() in ("1", "true", "yes")
40-
kwargs.setdefault("debug", debug)
41-
42-
sentry_sdk.Client.__init__(self, *args, **kwargs)
21+
def __init__(self, dsn: str | None = None, **options: object) -> None:
22+
"""Create a client, filling in options from the environment."""
23+
integrations = options.pop("integrations", None)
24+
merged: dict[str, object] = {
25+
"dsn": dsn if dsn is not None else os.environ.get("PYTEST_SENTRY_DSN"),
26+
"traces_sample_rate": float(os.environ.get("PYTEST_SENTRY_TRACES_SAMPLE_RATE", "1.0")),
27+
"profiles_sample_rate": float(
28+
os.environ.get("PYTEST_SENTRY_PROFILES_SAMPLE_RATE", "0.0")
29+
),
30+
"environment": os.environ.get("SENTRY_ENVIRONMENT", "test"),
31+
"debug": os.environ.get("PYTEST_SENTRY_DEBUG", "").lower() in _TRUTHY,
32+
} | options
33+
merged["integrations"] = [
34+
*(integrations if isinstance(integrations, list) else []),
35+
PytestIntegration(),
36+
]
37+
# Options come from users and are forwarded as-is; the SDK validates them.
38+
sentry_sdk.Client.__init__(self, **cast("dict[str, Any]", merged))

0 commit comments

Comments
 (0)