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.
@@ -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