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.
- pytest_testpulse-1.0.0/LICENSE +21 -0
- pytest_testpulse-1.0.0/PKG-INFO +118 -0
- pytest_testpulse-1.0.0/README.md +97 -0
- pytest_testpulse-1.0.0/pyproject.toml +50 -0
- pytest_testpulse-1.0.0/setup.cfg +4 -0
- pytest_testpulse-1.0.0/src/pytest_testpulse/__init__.py +3 -0
- pytest_testpulse-1.0.0/src/pytest_testpulse/plugin.py +373 -0
- pytest_testpulse-1.0.0/src/pytest_testpulse.egg-info/PKG-INFO +118 -0
- pytest_testpulse-1.0.0/src/pytest_testpulse.egg-info/SOURCES.txt +17 -0
- pytest_testpulse-1.0.0/src/pytest_testpulse.egg-info/dependency_links.txt +1 -0
- pytest_testpulse-1.0.0/src/pytest_testpulse.egg-info/entry_points.txt +2 -0
- pytest_testpulse-1.0.0/src/pytest_testpulse.egg-info/requires.txt +7 -0
- pytest_testpulse-1.0.0/src/pytest_testpulse.egg-info/top_level.txt +1 -0
- pytest_testpulse-1.0.0/tests/test_attachments.py +108 -0
- pytest_testpulse-1.0.0/tests/test_config.py +92 -0
- pytest_testpulse-1.0.0/tests/test_dry_run.py +104 -0
- pytest_testpulse-1.0.0/tests/test_marker.py +75 -0
- pytest_testpulse-1.0.0/tests/test_requirements.py +74 -0
- pytest_testpulse-1.0.0/tests/test_submission.py +139 -0
|
@@ -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,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
|