pytest-testinel 0.1.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_testinel-0.1.0/LICENSE +21 -0
- pytest_testinel-0.1.0/PKG-INFO +40 -0
- pytest_testinel-0.1.0/README.md +20 -0
- pytest_testinel-0.1.0/pyproject.toml +43 -0
- pytest_testinel-0.1.0/src/pytest_testinel/__init__.py +0 -0
- pytest_testinel-0.1.0/src/pytest_testinel/results_reporter.py +90 -0
- pytest_testinel-0.1.0/src/pytest_testinel/testinel.py +132 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Testinel
|
|
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,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-testinel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Testinel’s pytest plugin captures structured test execution data directly from pytest and sends it to Testinel, where your test results become searchable, comparable, and actually useful.
|
|
5
|
+
Author: Volodymyr Obrizan
|
|
6
|
+
Author-email: Volodymyr Obrizan <obrizan@first.institute>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Pytest
|
|
13
|
+
Classifier: Topic :: Software Development :: Testing
|
|
14
|
+
Requires-Dist: pytest>=7
|
|
15
|
+
Requires-Dist: requests>=2
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Project-URL: Homepage, https://github.com/Testinel/pytest-testinel
|
|
18
|
+
Project-URL: Issues, https://github.com/Testinel/pytest-testinel/issues
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
## Official Testinel plugin for pytest
|
|
22
|
+
|
|
23
|
+
Testinel’s pytest plugin captures structured test execution data directly from pytest and sends it to Testinel, where your test results become searchable, comparable, and actually useful. No log scraping. No brittle CI hacks. Just deterministic test analytics.
|
|
24
|
+
|
|
25
|
+
## 📦 Getting Started
|
|
26
|
+
### Prerequisites
|
|
27
|
+
|
|
28
|
+
You need a Testinel [account](https://testinel.first.institute/accounts/signup/?next=/projects/) and [project](https://testinel.first.institute/projects/).
|
|
29
|
+
|
|
30
|
+
### Installation
|
|
31
|
+
|
|
32
|
+
Getting Testinel into your project is straightforward. Just run this command in your terminal:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install --upgrade pytest-testinel
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Configuration
|
|
39
|
+
|
|
40
|
+
Set Testinel reporter DSN environment variable `TESTINEL_DSN`.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
## Official Testinel plugin for pytest
|
|
2
|
+
|
|
3
|
+
Testinel’s pytest plugin captures structured test execution data directly from pytest and sends it to Testinel, where your test results become searchable, comparable, and actually useful. No log scraping. No brittle CI hacks. Just deterministic test analytics.
|
|
4
|
+
|
|
5
|
+
## 📦 Getting Started
|
|
6
|
+
### Prerequisites
|
|
7
|
+
|
|
8
|
+
You need a Testinel [account](https://testinel.first.institute/accounts/signup/?next=/projects/) and [project](https://testinel.first.institute/projects/).
|
|
9
|
+
|
|
10
|
+
### Installation
|
|
11
|
+
|
|
12
|
+
Getting Testinel into your project is straightforward. Just run this command in your terminal:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
pip install --upgrade pytest-testinel
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### Configuration
|
|
19
|
+
|
|
20
|
+
Set Testinel reporter DSN environment variable `TESTINEL_DSN`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pytest-testinel"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Testinel’s pytest plugin captures structured test execution data directly from pytest and sends it to Testinel, where your test results become searchable, comparable, and actually useful."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name="Volodymyr Obrizan", email="obrizan@first.institute" },
|
|
9
|
+
]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pytest>=7",
|
|
12
|
+
"requests>=2",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Framework :: Pytest",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
]
|
|
21
|
+
license = "MIT"
|
|
22
|
+
license-files = ["LICENSE"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/Testinel/pytest-testinel"
|
|
26
|
+
Issues = "https://github.com/Testinel/pytest-testinel/issues"
|
|
27
|
+
|
|
28
|
+
[project.entry-points.pytest11]
|
|
29
|
+
myproject = "pytest_testinel.testinel"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = [
|
|
33
|
+
"uv_build >= 0.9.18, <0.10.0",
|
|
34
|
+
"ruff>=0.14.10",
|
|
35
|
+
"twine>=6.2.0",
|
|
36
|
+
]
|
|
37
|
+
build-backend = "uv_build"
|
|
38
|
+
|
|
39
|
+
[[tool.uv.index]]
|
|
40
|
+
name = "testpypi"
|
|
41
|
+
url = "https://test.pypi.org/simple/"
|
|
42
|
+
publish-url = "https://test.pypi.org/legacy/"
|
|
43
|
+
explicit = true
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import abc, datetime, json, uuid
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ReportingBackend(abc.ABC):
|
|
7
|
+
@abc.abstractmethod
|
|
8
|
+
def record_event(self, event: dict) -> None: ...
|
|
9
|
+
|
|
10
|
+
def on_start(self) -> None:
|
|
11
|
+
return
|
|
12
|
+
|
|
13
|
+
def on_end(self) -> None:
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FileReportingBackend(ReportingBackend):
|
|
18
|
+
events: list[dict]
|
|
19
|
+
filename: str
|
|
20
|
+
indent: int | None
|
|
21
|
+
|
|
22
|
+
def __init__(self, filename: str, indent: int | None = None):
|
|
23
|
+
self.events = []
|
|
24
|
+
self.filename = filename
|
|
25
|
+
self.indent = indent
|
|
26
|
+
|
|
27
|
+
def record_event(self, event: dict) -> None:
|
|
28
|
+
self.events.append(event)
|
|
29
|
+
|
|
30
|
+
def on_end(self) -> None:
|
|
31
|
+
with open(self.filename, "w", encoding="utf-8") as f:
|
|
32
|
+
json.dump(self.events, f, indent=self.indent)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HttpReportingBackend(ReportingBackend):
|
|
36
|
+
url: str
|
|
37
|
+
|
|
38
|
+
def __init__(self, url: str):
|
|
39
|
+
self.url = url
|
|
40
|
+
|
|
41
|
+
def record_event(self, event: dict) -> None:
|
|
42
|
+
requests.post(self.url, json=event, verify=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ResultsReporter:
|
|
46
|
+
run_id: str
|
|
47
|
+
dsn: str
|
|
48
|
+
backend: ReportingBackend
|
|
49
|
+
tests: list[dict]
|
|
50
|
+
|
|
51
|
+
def __init__(self, dsn: str, backend: ReportingBackend | None = None):
|
|
52
|
+
self.dsn = dsn
|
|
53
|
+
self.run_id = str(uuid.uuid4())
|
|
54
|
+
self.tests = []
|
|
55
|
+
if backend:
|
|
56
|
+
self.backend = backend
|
|
57
|
+
else:
|
|
58
|
+
self.backend = HttpReportingBackend(url=dsn)
|
|
59
|
+
|
|
60
|
+
def report_start(self, payload: dict) -> None:
|
|
61
|
+
self.backend.on_start()
|
|
62
|
+
self.backend.record_event(
|
|
63
|
+
{
|
|
64
|
+
"run_id": self.run_id,
|
|
65
|
+
"event": "start",
|
|
66
|
+
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
|
|
67
|
+
"payload": payload,
|
|
68
|
+
"tests": self.tests,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def report_end(self) -> None:
|
|
73
|
+
self.backend.record_event(
|
|
74
|
+
{
|
|
75
|
+
"run_id": self.run_id,
|
|
76
|
+
"event": "end",
|
|
77
|
+
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
self.backend.on_end()
|
|
81
|
+
|
|
82
|
+
def report_event(self, event: str, payload: dict) -> None:
|
|
83
|
+
self.backend.record_event(
|
|
84
|
+
{
|
|
85
|
+
"run_id": self.run_id,
|
|
86
|
+
"event": event,
|
|
87
|
+
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
|
|
88
|
+
"payload": payload,
|
|
89
|
+
}
|
|
90
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import traceback
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
from itertools import dropwhile
|
|
5
|
+
from typing import Callable, Generator, Any, Final, Set
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from _pytest._code.code import ExceptionChainRepr
|
|
9
|
+
|
|
10
|
+
from .results_reporter import ResultsReporter
|
|
11
|
+
|
|
12
|
+
ENV_VAR_WHITELIST: Final[Set] = {
|
|
13
|
+
"CI",
|
|
14
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
15
|
+
"BITBUCKET_COMMIT",
|
|
16
|
+
"BITBUCKET_WORKSPACE",
|
|
17
|
+
"BITBUCKET_REPO_SLUG",
|
|
18
|
+
"BITBUCKET_REPO_UUID",
|
|
19
|
+
"BITBUCKET_REPO_FULL_NAME",
|
|
20
|
+
"BITBUCKET_BRANCH",
|
|
21
|
+
"BITBUCKET_TAG",
|
|
22
|
+
"BITBUCKET_BOOKMARK",
|
|
23
|
+
"BITBUCKET_PARALLEL_STEP",
|
|
24
|
+
"BITBUCKET_PARALLEL_STEP_COUNT",
|
|
25
|
+
"BITBUCKET_PR_ID",
|
|
26
|
+
"BITBUCKET_PR_DESTINATION_BRANCH",
|
|
27
|
+
"BITBUCKET_GIT_HTTP_ORIGIN",
|
|
28
|
+
"BITBUCKET_GIT_SSH_ORIGIN",
|
|
29
|
+
"BITBUCKET_STEP_UUID",
|
|
30
|
+
"BITBUCKET_PIPELINE_UUID",
|
|
31
|
+
"BITBUCKET_PROJECT_KEY",
|
|
32
|
+
"BITBUCKET_PROJECT_UUID",
|
|
33
|
+
"BITBUCKET_STEP_RUN_NUMBER",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test_reporter = ResultsReporter(
|
|
37
|
+
dsn=os.environ["TESTINEL_DSN"],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def serialize_repr(long_repr: ExceptionChainRepr) -> dict:
|
|
41
|
+
return asdict(long_repr)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def to_test_dict(item: Callable) -> dict:
|
|
45
|
+
test_cls_docstring = item.parent.obj.__doc__ or ""
|
|
46
|
+
test_fn_docstring = item.obj.__doc__ or ""
|
|
47
|
+
return {
|
|
48
|
+
"test_id": item.nodeid,
|
|
49
|
+
"location": item.location,
|
|
50
|
+
"description": test_fn_docstring or test_cls_docstring,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
55
|
+
def pytest_runtest_makereport(
|
|
56
|
+
item: Callable,
|
|
57
|
+
call,
|
|
58
|
+
) -> Generator[None, Any, None]:
|
|
59
|
+
"""Pytest hook that wraps the standard pytest_runtest_makereport
|
|
60
|
+
function and grabs the results for the 'call' phase of each test.
|
|
61
|
+
"""
|
|
62
|
+
outcome = yield
|
|
63
|
+
report = outcome.get_result()
|
|
64
|
+
|
|
65
|
+
report.exception = None
|
|
66
|
+
ss = None
|
|
67
|
+
exc_info = None
|
|
68
|
+
repr_info = None
|
|
69
|
+
if report.outcome == "failed":
|
|
70
|
+
# driver = item.funcargs["driver"]
|
|
71
|
+
# logs = driver.get_log('browser')
|
|
72
|
+
# current_url = driver.current_url
|
|
73
|
+
exc = call.excinfo.value
|
|
74
|
+
|
|
75
|
+
tb_frames = traceback.extract_tb(call.excinfo.value.__traceback__)
|
|
76
|
+
filtered_frames = dropwhile(
|
|
77
|
+
lambda t: not item.location[0] in t.filename, tb_frames
|
|
78
|
+
)
|
|
79
|
+
ss = traceback.StackSummary.from_list(filtered_frames)
|
|
80
|
+
|
|
81
|
+
exc_info = {
|
|
82
|
+
"type": f"{exc.__class__.__module__}.{exc.__class__.__name__}",
|
|
83
|
+
"message": str(exc),
|
|
84
|
+
"notes": list(getattr(exc, "__notes__", []) or []),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
repr_info = serialize_repr(report.longrepr)
|
|
88
|
+
|
|
89
|
+
test_reporter.report_event(
|
|
90
|
+
event=report.when,
|
|
91
|
+
payload={
|
|
92
|
+
"test": to_test_dict(item),
|
|
93
|
+
"outcome": report.outcome,
|
|
94
|
+
"duration": report.duration,
|
|
95
|
+
"error_info": {
|
|
96
|
+
"repr_info": repr_info,
|
|
97
|
+
"traceback": [
|
|
98
|
+
{
|
|
99
|
+
"filename": f.filename,
|
|
100
|
+
"lineno": f.lineno,
|
|
101
|
+
"name": f.name,
|
|
102
|
+
"line": f.line,
|
|
103
|
+
}
|
|
104
|
+
for f in ss
|
|
105
|
+
],
|
|
106
|
+
"exception": exc_info,
|
|
107
|
+
}
|
|
108
|
+
if report.outcome == "failed"
|
|
109
|
+
else None,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
115
|
+
def reporter(request):
|
|
116
|
+
config = request.config
|
|
117
|
+
test_reporter.report_start(
|
|
118
|
+
payload={
|
|
119
|
+
"args": config.args,
|
|
120
|
+
"options": vars(config.option),
|
|
121
|
+
"environment": {
|
|
122
|
+
key: os.environ[key] for key in os.environ if key in ENV_VAR_WHITELIST
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
yield
|
|
127
|
+
test_reporter.report_end()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def pytest_collection_finish(session):
|
|
131
|
+
tests = [to_test_dict(item) for item in session.items]
|
|
132
|
+
test_reporter.tests = tests
|