pytest-testpulse 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Barayo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-testpulse
3
+ Version: 1.0.0
4
+ Summary: Report pytest results into TestPulse via its JUnit-XML import API
5
+ Author: TestPulse
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Barayo/pytest-testpulse
8
+ Classifier: Framework :: Pytest
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: pytest>=7.0
15
+ Requires-Dist: requests>=2.28
16
+ Provides-Extra: dev
17
+ Requires-Dist: responses>=0.23; extra == "dev"
18
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
19
+ Requires-Dist: python-semantic-release>=9.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # pytest-testpulse
23
+
24
+ A pytest plugin that reports test results into [TestPulse](https://github.com/Barayo/TestPulse)
25
+ via its JUnit-XML import API, matching each test to an existing case by key.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install pytest-testpulse
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ Mark a test with the case key it corresponds to in TestPulse:
36
+
37
+ ```python
38
+ import pytest
39
+
40
+ @pytest.mark.testpulse("LOGIN-42", platform="linux", tags=["smoke"])
41
+ def test_login_succeeds():
42
+ ...
43
+ ```
44
+
45
+ Run with `--junitxml` — the plugin reads pytest's own JUnit XML output rather
46
+ than generating it itself, so this flag is required whenever the marker (or
47
+ `--testpulse-dry-run`) is in use:
48
+
49
+ ```bash
50
+ pytest --junitxml=report.xml
51
+ ```
52
+
53
+ At the end of the session, the plugin submits `report.xml` to
54
+ `POST /api/v1/projects/{project}/imports` and prints a summary. A suite with
55
+ no marked tests and no `--testpulse-dry-run` needs no configuration at all —
56
+ the plugin does nothing.
57
+
58
+ ## Configuration
59
+
60
+ Each setting resolves in this order: **CLI flag › environment variable ›
61
+ `pytest.ini`/`pyproject.toml` option** — the first one present wins.
62
+
63
+ | Setting | CLI flag | Env var | ini-option |
64
+ |---|---|---|---|
65
+ | API base URL | `--testpulse-url` | `TESTPULSE_URL` | `testpulse_url` |
66
+ | API token | `--testpulse-token` | `TESTPULSE_TOKEN` | `testpulse_token` |
67
+ | Project key | `--testpulse-project` | `TESTPULSE_PROJECT` | `testpulse_project` |
68
+ | Fail on unmatched | `--testpulse-fail-on-unmatched` / `--testpulse-no-fail-on-unmatched` | `TESTPULSE_FAIL_ON_UNMATCHED` | `testpulse_fail_on_unmatched` |
69
+
70
+ `TESTPULSE_URL`/`TESTPULSE_TOKEN` match the env var names TestPulse's own
71
+ docs already use for the raw `conftest.py` snippet, so migrating from that
72
+ snippet to this plugin doesn't require renaming CI secrets. Put your token in
73
+ an env var or CI secret — not in a committed ini-option.
74
+
75
+ `testpulse_fail_on_unmatched` (env var and ini-option) accepts
76
+ `1`/`true`/`yes` (case-insensitive) as true; anything else, including unset,
77
+ is false. `--testpulse-no-fail-on-unmatched` overrides an ini-option or env
78
+ var set to true, for a single local run.
79
+
80
+ ## Exit-code behavior
81
+
82
+ | Import response | Effect on `pytest`'s exit code |
83
+ |---|---|
84
+ | `201` — every test matched | Untouched; a summary is printed to stderr. |
85
+ | `207` — some tests unmatched | Untouched by default (a warning is printed, naming the unmatched case keys and `--testpulse-fail-on-unmatched` as the escalation path). Set `--testpulse-fail-on-unmatched` to make this a hard failure instead. |
86
+ | Network error, auth failure, non-2xx | **Always** fails the session — this isn't suppressible, since a broken submission is a different problem than an incomplete case-annotation rollout. |
87
+
88
+ ## Dry run
89
+
90
+ `--testpulse-dry-run` previews which annotated case keys would match, via a
91
+ **read-only** fetch of the project's cases — it never calls the import
92
+ endpoint, never creates a run, and never affects the exit code:
93
+
94
+ ```bash
95
+ pytest --junitxml=report.xml --testpulse-dry-run
96
+ ```
97
+
98
+ ## Attachments
99
+
100
+ Call the `testpulse_attach` fixture from inside a marked test to attach a
101
+ screenshot or file to its case:
102
+
103
+ ```python
104
+ @pytest.mark.testpulse("LOGIN-42")
105
+ def test_login_succeeds(testpulse_attach):
106
+ ...
107
+ testpulse_attach("failure.png")
108
+ ```
109
+
110
+ Calling it on a test with no `@pytest.mark.testpulse` marker drops the
111
+ attachment with a warning — there's no case key to attach it to. If a
112
+ marked test's case comes back unmatched in a `207` response, its attachment
113
+ is named separately in the warning output, distinct from the plain
114
+ unmatched-case listing.
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,97 @@
1
+ # pytest-testpulse
2
+
3
+ A pytest plugin that reports test results into [TestPulse](https://github.com/Barayo/TestPulse)
4
+ via its JUnit-XML import API, matching each test to an existing case by key.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install pytest-testpulse
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ Mark a test with the case key it corresponds to in TestPulse:
15
+
16
+ ```python
17
+ import pytest
18
+
19
+ @pytest.mark.testpulse("LOGIN-42", platform="linux", tags=["smoke"])
20
+ def test_login_succeeds():
21
+ ...
22
+ ```
23
+
24
+ Run with `--junitxml` — the plugin reads pytest's own JUnit XML output rather
25
+ than generating it itself, so this flag is required whenever the marker (or
26
+ `--testpulse-dry-run`) is in use:
27
+
28
+ ```bash
29
+ pytest --junitxml=report.xml
30
+ ```
31
+
32
+ At the end of the session, the plugin submits `report.xml` to
33
+ `POST /api/v1/projects/{project}/imports` and prints a summary. A suite with
34
+ no marked tests and no `--testpulse-dry-run` needs no configuration at all —
35
+ the plugin does nothing.
36
+
37
+ ## Configuration
38
+
39
+ Each setting resolves in this order: **CLI flag › environment variable ›
40
+ `pytest.ini`/`pyproject.toml` option** — the first one present wins.
41
+
42
+ | Setting | CLI flag | Env var | ini-option |
43
+ |---|---|---|---|
44
+ | API base URL | `--testpulse-url` | `TESTPULSE_URL` | `testpulse_url` |
45
+ | API token | `--testpulse-token` | `TESTPULSE_TOKEN` | `testpulse_token` |
46
+ | Project key | `--testpulse-project` | `TESTPULSE_PROJECT` | `testpulse_project` |
47
+ | Fail on unmatched | `--testpulse-fail-on-unmatched` / `--testpulse-no-fail-on-unmatched` | `TESTPULSE_FAIL_ON_UNMATCHED` | `testpulse_fail_on_unmatched` |
48
+
49
+ `TESTPULSE_URL`/`TESTPULSE_TOKEN` match the env var names TestPulse's own
50
+ docs already use for the raw `conftest.py` snippet, so migrating from that
51
+ snippet to this plugin doesn't require renaming CI secrets. Put your token in
52
+ an env var or CI secret — not in a committed ini-option.
53
+
54
+ `testpulse_fail_on_unmatched` (env var and ini-option) accepts
55
+ `1`/`true`/`yes` (case-insensitive) as true; anything else, including unset,
56
+ is false. `--testpulse-no-fail-on-unmatched` overrides an ini-option or env
57
+ var set to true, for a single local run.
58
+
59
+ ## Exit-code behavior
60
+
61
+ | Import response | Effect on `pytest`'s exit code |
62
+ |---|---|
63
+ | `201` — every test matched | Untouched; a summary is printed to stderr. |
64
+ | `207` — some tests unmatched | Untouched by default (a warning is printed, naming the unmatched case keys and `--testpulse-fail-on-unmatched` as the escalation path). Set `--testpulse-fail-on-unmatched` to make this a hard failure instead. |
65
+ | Network error, auth failure, non-2xx | **Always** fails the session — this isn't suppressible, since a broken submission is a different problem than an incomplete case-annotation rollout. |
66
+
67
+ ## Dry run
68
+
69
+ `--testpulse-dry-run` previews which annotated case keys would match, via a
70
+ **read-only** fetch of the project's cases — it never calls the import
71
+ endpoint, never creates a run, and never affects the exit code:
72
+
73
+ ```bash
74
+ pytest --junitxml=report.xml --testpulse-dry-run
75
+ ```
76
+
77
+ ## Attachments
78
+
79
+ Call the `testpulse_attach` fixture from inside a marked test to attach a
80
+ screenshot or file to its case:
81
+
82
+ ```python
83
+ @pytest.mark.testpulse("LOGIN-42")
84
+ def test_login_succeeds(testpulse_attach):
85
+ ...
86
+ testpulse_attach("failure.png")
87
+ ```
88
+
89
+ Calling it on a test with no `@pytest.mark.testpulse` marker drops the
90
+ attachment with a warning — there's no case key to attach it to. If a
91
+ marked test's case comes back unmatched in a `207` response, its attachment
92
+ is named separately in the warning output, distinct from the plain
93
+ unmatched-case listing.
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pytest-testpulse"
7
+ version = "1.0.0"
8
+ description = "Report pytest results into TestPulse via its JUnit-XML import API"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "TestPulse" }]
13
+ classifiers = [
14
+ "Framework :: Pytest",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ ]
18
+ dependencies = [
19
+ "pytest>=7.0",
20
+ "requests>=2.28",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/Barayo/pytest-testpulse"
25
+
26
+ [project.entry-points.pytest11]
27
+ testpulse = "pytest_testpulse.plugin"
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "responses>=0.23",
32
+ "pytest-cov>=4.0",
33
+ "python-semantic-release>=9.0",
34
+ ]
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+
41
+ [tool.semantic_release]
42
+ version_toml = ["pyproject.toml:project.version"]
43
+ commit_message = "chore(release): {version}"
44
+ build_command = "pip install build && python -m build"
45
+
46
+ [tool.semantic_release.branches.main]
47
+ match = "main"
48
+
49
+ [tool.semantic_release.remote.token]
50
+ env = "GH_TOKEN"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,373 @@
1
+ """pytest plugin: link tests to TestPulse cases and report results on import."""
2
+
3
+ import base64
4
+ import mimetypes
5
+ import os
6
+ import sys
7
+
8
+ import pytest
9
+ import requests
10
+
11
+
12
+ class ReportUnavailable(Exception):
13
+ """Raised when the --junitxml report is missing or unreadable at session end."""
14
+
15
+
16
+ def _read_report(xmlpath):
17
+ if not xmlpath or not os.path.isfile(xmlpath):
18
+ raise ReportUnavailable(
19
+ f"pytest-testpulse: expected report at {xmlpath!r} but it does not "
20
+ "exist. This usually means the run was interrupted before pytest's "
21
+ "own --junitxml writer finished."
22
+ )
23
+ with open(xmlpath, "r", encoding="utf-8") as fh:
24
+ return fh.read()
25
+
26
+
27
+ _SETTINGS = {
28
+ "url": ("testpulse_url", "TESTPULSE_URL", "testpulse_url"),
29
+ "token": ("testpulse_token", "TESTPULSE_TOKEN", "testpulse_token"),
30
+ "project": ("testpulse_project", "TESTPULSE_PROJECT", "testpulse_project"),
31
+ }
32
+
33
+
34
+ def pytest_addoption(parser):
35
+ group = parser.getgroup("testpulse")
36
+ group.addoption(
37
+ "--testpulse-url", dest="testpulse_url", default=None,
38
+ help="TestPulse API base URL",
39
+ )
40
+ group.addoption(
41
+ "--testpulse-token", dest="testpulse_token", default=None,
42
+ help="TestPulse API token",
43
+ )
44
+ group.addoption(
45
+ "--testpulse-project", dest="testpulse_project", default=None,
46
+ help="TestPulse project key",
47
+ )
48
+ group.addoption(
49
+ "--testpulse-fail-on-unmatched", dest="testpulse_fail_on_unmatched",
50
+ action="store_true", default=None,
51
+ help="Fail the session when the import reports unmatched tests (207)",
52
+ )
53
+ group.addoption(
54
+ "--testpulse-no-fail-on-unmatched", dest="testpulse_fail_on_unmatched",
55
+ action="store_false",
56
+ help="Do not fail the session on unmatched tests, overriding config",
57
+ )
58
+ group.addoption(
59
+ "--testpulse-dry-run", dest="testpulse_dry_run",
60
+ action="store_true", default=False,
61
+ help="Preview matches via a read-only cases fetch, without submitting",
62
+ )
63
+
64
+ parser.addini("testpulse_url", help="TestPulse API base URL", default=None)
65
+ parser.addini("testpulse_token", help="TestPulse API token", default=None)
66
+ parser.addini("testpulse_project", help="TestPulse project key", default=None)
67
+ parser.addini(
68
+ "testpulse_fail_on_unmatched",
69
+ help="Fail the session when the import reports unmatched tests (207)",
70
+ default=None,
71
+ )
72
+
73
+
74
+ def resolve_setting(config, name):
75
+ option_dest, env_var, ini_name = _SETTINGS[name]
76
+
77
+ cli_value = getattr(config.option, option_dest, None)
78
+ if cli_value:
79
+ return cli_value
80
+
81
+ env_value = os.environ.get(env_var)
82
+ if env_value:
83
+ return env_value
84
+
85
+ ini_value = config.getini(ini_name)
86
+ if ini_value:
87
+ return ini_value
88
+
89
+ return None
90
+
91
+
92
+ _TRUTHY = {"1", "true", "yes"}
93
+
94
+
95
+ def _parse_bool(value):
96
+ if value is None:
97
+ return False
98
+ return str(value).strip().lower() in _TRUTHY
99
+
100
+
101
+ def resolve_fail_on_unmatched(config):
102
+ cli_value = getattr(config.option, "testpulse_fail_on_unmatched", None)
103
+ if cli_value is not None:
104
+ return bool(cli_value)
105
+
106
+ env_value = os.environ.get("TESTPULSE_FAIL_ON_UNMATCHED")
107
+ if env_value is not None:
108
+ return _parse_bool(env_value)
109
+
110
+ ini_value = config.getini("testpulse_fail_on_unmatched")
111
+ if ini_value:
112
+ return _parse_bool(ini_value)
113
+
114
+ return False
115
+
116
+
117
+ def pytest_configure(config):
118
+ config.addinivalue_line(
119
+ "markers",
120
+ "testpulse(case_key, platform=None, version=None, tags=None): "
121
+ "link this test to a TestPulse case for result import",
122
+ )
123
+ config._testpulse_attachment_warnings = []
124
+
125
+
126
+ def _plugin_is_active(config, items):
127
+ if getattr(config.option, "testpulse_dry_run", False):
128
+ return True
129
+ return any(item.get_closest_marker("testpulse") is not None for item in items)
130
+
131
+
132
+ def pytest_collection_modifyitems(config, items):
133
+ if not _plugin_is_active(config, items):
134
+ return
135
+
136
+ if not config.option.xmlpath:
137
+ raise pytest.UsageError(
138
+ "pytest-testpulse: --junitxml is required when using "
139
+ "@pytest.mark.testpulse or --testpulse-dry-run (the plugin reads "
140
+ "pytest's own JUnit XML output rather than generating it)."
141
+ )
142
+
143
+ for name in ("url", "token", "project"):
144
+ if resolve_setting(config, name) is None:
145
+ option_dest, env_var, ini_name = _SETTINGS[name]
146
+ raise pytest.UsageError(
147
+ f"pytest-testpulse: missing required setting {name!r}. Set it via "
148
+ f"--{option_dest.replace('_', '-')}, the {env_var} environment "
149
+ f"variable, or the {ini_name} ini-option."
150
+ )
151
+
152
+ config._testpulse_marked_items = [
153
+ item for item in items if item.get_closest_marker("testpulse") is not None
154
+ ]
155
+ config._testpulse_attachments = []
156
+
157
+
158
+ def _import_endpoint(url, project):
159
+ return f"{url.rstrip('/')}/api/v1/projects/{project}/imports"
160
+
161
+
162
+ def _submit_report(config, report, attachments):
163
+ url = resolve_setting(config, "url")
164
+ token = resolve_setting(config, "token")
165
+ project = resolve_setting(config, "project")
166
+
167
+ payload = {"format": "junit-xml", "report": report}
168
+ if attachments:
169
+ payload["attachments"] = attachments
170
+
171
+ return requests.post(
172
+ _import_endpoint(url, project),
173
+ json=payload,
174
+ headers={"Authorization": f"Bearer {token}"},
175
+ timeout=30,
176
+ )
177
+
178
+
179
+ def _cases_endpoint(url, project):
180
+ return f"{url.rstrip('/')}/api/v1/projects/{project}/cases"
181
+
182
+
183
+ def _marked_case_keys(marked_items):
184
+ keys = []
185
+ for item in marked_items:
186
+ marker = item.get_closest_marker("testpulse")
187
+ case_key = marker.args[0] if marker.args else marker.kwargs.get("case_key")
188
+ if case_key:
189
+ keys.append(case_key)
190
+ return keys
191
+
192
+
193
+ def _run_dry_run(config, marked_items):
194
+ url = resolve_setting(config, "url")
195
+ token = resolve_setting(config, "token")
196
+ project = resolve_setting(config, "project")
197
+
198
+ try:
199
+ response = requests.get(
200
+ _cases_endpoint(url, project),
201
+ headers={"Authorization": f"Bearer {token}"},
202
+ timeout=30,
203
+ )
204
+ except requests.RequestException as exc:
205
+ print(f"pytest-testpulse: dry-run cases fetch failed: {exc}", file=sys.stderr)
206
+ return False
207
+
208
+ if response.status_code != 200:
209
+ print(
210
+ f"pytest-testpulse: dry-run cases fetch failed "
211
+ f"({response.status_code}): {response.text}",
212
+ file=sys.stderr,
213
+ )
214
+ return False
215
+
216
+ existing_keys = {case.get("key") for case in response.json()}
217
+ annotated_keys = _marked_case_keys(marked_items)
218
+ matched = [key for key in annotated_keys if key in existing_keys]
219
+ unmatched = [key for key in annotated_keys if key not in existing_keys]
220
+
221
+ for key in matched:
222
+ print(f"pytest-testpulse: dry-run: {key} would match", file=sys.stdout)
223
+ for key in unmatched:
224
+ print(f"pytest-testpulse: dry-run: {key} would be unmatched", file=sys.stdout)
225
+
226
+ return True
227
+
228
+
229
+ def pytest_sessionfinish(session, exitstatus):
230
+ config = session.config
231
+
232
+ for warning in getattr(config, "_testpulse_attachment_warnings", []):
233
+ print(warning, file=sys.stderr)
234
+
235
+ marked_items = getattr(config, "_testpulse_marked_items", None)
236
+ if getattr(config.option, "testpulse_dry_run", False):
237
+ ok = _run_dry_run(config, marked_items or [])
238
+ if not ok:
239
+ session.exitstatus = 1
240
+ return
241
+ if not marked_items:
242
+ return
243
+
244
+ try:
245
+ report = _read_report(config.option.xmlpath)
246
+ except ReportUnavailable as exc:
247
+ print(str(exc), file=sys.stderr)
248
+ session.exitstatus = 1
249
+ return
250
+
251
+ attachments = getattr(config, "_testpulse_attachments", [])
252
+
253
+ try:
254
+ response = _submit_report(config, report, attachments)
255
+ except requests.RequestException as exc:
256
+ print(f"pytest-testpulse: submission failed: {exc}", file=sys.stderr)
257
+ session.exitstatus = 1
258
+ return
259
+
260
+ total = len(marked_items)
261
+
262
+ if response.status_code == 201:
263
+ # The 201 body is the created Run object directly (importResults'
264
+ # 201 response schema is a bare $ref: Run, not {"run": ...}).
265
+ run = response.json()
266
+ print(
267
+ f"pytest-testpulse: {total}/{total} matched, "
268
+ f"run {run.get('key', run.get('id', '?'))} created",
269
+ file=sys.stderr,
270
+ )
271
+ return
272
+
273
+ if response.status_code == 207:
274
+ body = response.json()
275
+ run = body.get("run", {})
276
+ unmatched = body.get("unmatched", [])
277
+ matched = body.get("matched", total - len(unmatched))
278
+ unmatched_keys = {entry.get("caseKey") for entry in unmatched}
279
+ unmatched_list = ", ".join(sorted(k for k in unmatched_keys if k))
280
+ print(
281
+ f"pytest-testpulse: {matched}/{total} matched, "
282
+ f"run {run.get('key', run.get('id', '?'))} created. "
283
+ f"Unmatched: {unmatched_list}. Re-run with "
284
+ "--testpulse-fail-on-unmatched to make this a hard failure.",
285
+ file=sys.stderr,
286
+ )
287
+ dropped_attachment_keys = sorted(
288
+ {a.get("caseKey") for a in attachments if a.get("caseKey") in unmatched_keys}
289
+ )
290
+ for key in dropped_attachment_keys:
291
+ print(
292
+ f"pytest-testpulse: {key} is unmatched, and its attachment "
293
+ "was not saved.",
294
+ file=sys.stderr,
295
+ )
296
+ if resolve_fail_on_unmatched(config):
297
+ session.exitstatus = 1
298
+ return
299
+
300
+ print(
301
+ f"pytest-testpulse: submission rejected ({response.status_code}): "
302
+ f"{response.text}",
303
+ file=sys.stderr,
304
+ )
305
+ session.exitstatus = 1
306
+
307
+
308
+ @pytest.fixture(autouse=True)
309
+ def _testpulse_case_link(request, record_property):
310
+ marker = request.node.get_closest_marker("testpulse")
311
+ if marker is None:
312
+ return
313
+
314
+ case_key = marker.args[0] if marker.args else marker.kwargs.get("case_key")
315
+ if case_key:
316
+ record_property("testpulse_case_key", case_key)
317
+
318
+ platform = marker.kwargs.get("platform")
319
+ if platform:
320
+ record_property("testpulse_platform", platform)
321
+
322
+ version = marker.kwargs.get("version")
323
+ if version:
324
+ record_property("testpulse_version", version)
325
+
326
+ tags = marker.kwargs.get("tags")
327
+ if tags:
328
+ record_property("testpulse_tags", ",".join(tags))
329
+
330
+
331
+ def _attachment_bytes(path_or_bytes):
332
+ if isinstance(path_or_bytes, (bytes, bytearray)):
333
+ return bytes(path_or_bytes)
334
+ with open(path_or_bytes, "rb") as fh:
335
+ return fh.read()
336
+
337
+
338
+ @pytest.fixture
339
+ def testpulse_attach(request):
340
+ def _attach(path_or_bytes, filename=None, content_type=None):
341
+ marker = request.node.get_closest_marker("testpulse")
342
+ case_key = None
343
+ if marker is not None:
344
+ case_key = marker.args[0] if marker.args else marker.kwargs.get("case_key")
345
+
346
+ if not case_key:
347
+ request.config._testpulse_attachment_warnings.append(
348
+ f"pytest-testpulse: testpulse_attach() called on "
349
+ f"{request.node.nodeid} with no @pytest.mark.testpulse marker; "
350
+ "dropping attachment (no case key to attach it to)."
351
+ )
352
+ return
353
+
354
+ if filename is None:
355
+ filename = (
356
+ os.path.basename(path_or_bytes)
357
+ if isinstance(path_or_bytes, str)
358
+ else "attachment"
359
+ )
360
+ if content_type is None:
361
+ content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
362
+
363
+ data = _attachment_bytes(path_or_bytes)
364
+ request.config._testpulse_attachments.append(
365
+ {
366
+ "caseKey": case_key,
367
+ "filename": filename,
368
+ "contentType": content_type,
369
+ "data": base64.b64encode(data).decode("ascii"),
370
+ }
371
+ )
372
+
373
+ return _attach