Skip to content

Commit 852b546

Browse files
committed
ci: Add a simple plugin to report test results to our falkiness tracker
1 parent 534e3ae commit 852b546

File tree

6 files changed

+331
-1
lines changed

6 files changed

+331
-1
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# pytest-trackflaky
2+
3+
A pytest plugin to track and report test flakiness to a central server.
4+
5+
## Features
6+
7+
- Automatically tracks test execution times and outcomes
8+
- Collects GitHub Actions metadata (commit SHA, branch, run ID, etc.)
9+
- Reports test results to a configurable server endpoint
10+
- Zero configuration needed when running in CI environments
11+
12+
## Installation
13+
14+
Install the plugin using pip or uv:
15+
16+
```bash
17+
pip install -e contrib/pytest-trackflaky
18+
```
19+
20+
Or with uv:
21+
22+
```bash
23+
uv pip install -e contrib/pytest-trackflaky
24+
```
25+
26+
## Usage
27+
28+
Once installed, the plugin is automatically activated when running pytest. No additional configuration is needed.
29+
30+
### Configuration
31+
32+
The plugin is controlled via environment variables:
33+
34+
- `CI_SERVER_URL`: The base URL of the server to report results to (required for reporting)
35+
- Test results will be POSTed to `{CI_SERVER_URL}/hook/test`
36+
- `GITHUB_*`: Standard GitHub Actions environment variables are automatically collected
37+
38+
### Example
39+
40+
```bash
41+
export CI_SERVER_URL="https://your-flaky-tracker.example.com"
42+
pytest
43+
```
44+
45+
## Data Collected
46+
47+
For each test, the plugin collects:
48+
49+
- Test name
50+
- Outcome (success/skip/fail)
51+
- Start and end times
52+
- GitHub repository information
53+
- Git commit SHA and branch
54+
- GitHub Actions run metadata
55+
56+
## Development
57+
58+
To work on the plugin locally:
59+
60+
```bash
61+
cd contrib/pytest-trackflaky
62+
pip install -e .
63+
```
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[project]
2+
name = "pytest-trackflaky"
3+
version = "0.1.0"
4+
description = "A pytest plugin to track and report test flakiness"
5+
authors = [
6+
{name = "Lightning Development Team"}
7+
]
8+
readme = "README.md"
9+
requires-python = ">=3.8"
10+
dependencies = [
11+
"pytest>=7.0.0",
12+
]
13+
14+
[project.entry-points.pytest11]
15+
trackflaky = "pytest_trackflaky.plugin"
16+
17+
[build-system]
18+
requires = ["hatchling"]
19+
build-backend = "hatchling.build"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""pytest-trackflaky: A pytest plugin to track and report test flakiness."""
2+
3+
__version__ = "0.1.0"
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""pytest-trackflaky plugin implementation."""
2+
3+
import pytest
4+
import subprocess
5+
from urllib import request
6+
import os
7+
import json
8+
from time import time
9+
import unittest
10+
import threading
11+
12+
13+
# Global state for run tracking
14+
_run_id = None
15+
_run_id_lock = threading.Lock()
16+
17+
18+
class SnowflakeGenerator:
19+
"""
20+
Generates Twitter-style Snowflake IDs.
21+
22+
Format (64 bits):
23+
- 41 bits: timestamp in milliseconds since custom epoch
24+
- 10 bits: worker/machine ID
25+
- 12 bits: sequence number
26+
"""
27+
28+
# Custom epoch (2024-01-01 00:00:00 UTC in milliseconds)
29+
EPOCH = 1704067200000
30+
31+
# Bit allocation
32+
TIMESTAMP_BITS = 41
33+
WORKER_BITS = 10
34+
SEQUENCE_BITS = 12
35+
36+
# Max values
37+
MAX_WORKER_ID = (1 << WORKER_BITS) - 1
38+
MAX_SEQUENCE = (1 << SEQUENCE_BITS) - 1
39+
40+
# Bit shifts
41+
TIMESTAMP_SHIFT = WORKER_BITS + SEQUENCE_BITS
42+
WORKER_SHIFT = SEQUENCE_BITS
43+
44+
def __init__(self, worker_id=None):
45+
"""Initialize the snowflake generator."""
46+
if worker_id is None:
47+
# Try to get worker ID from environment or use process ID
48+
worker_id = os.getpid() & self.MAX_WORKER_ID
49+
50+
if worker_id > self.MAX_WORKER_ID or worker_id < 0:
51+
raise ValueError(f"Worker ID must be between 0 and {self.MAX_WORKER_ID}")
52+
53+
self.worker_id = worker_id
54+
self.sequence = 0
55+
self.last_timestamp = -1
56+
self.lock = threading.Lock()
57+
58+
def _current_timestamp(self):
59+
"""Get current timestamp in milliseconds since epoch."""
60+
return int(time() * 1000)
61+
62+
def generate(self):
63+
"""Generate a new Snowflake ID."""
64+
with self.lock:
65+
timestamp = self._current_timestamp() - self.EPOCH
66+
67+
if timestamp < self.last_timestamp:
68+
raise Exception("Clock moved backwards. Refusing to generate ID.")
69+
70+
if timestamp == self.last_timestamp:
71+
self.sequence = (self.sequence + 1) & self.MAX_SEQUENCE
72+
if self.sequence == 0:
73+
# Sequence exhausted, wait for next millisecond
74+
while timestamp <= self.last_timestamp:
75+
timestamp = self._current_timestamp() - self.EPOCH
76+
else:
77+
self.sequence = 0
78+
79+
self.last_timestamp = timestamp
80+
81+
# Combine all parts
82+
snowflake_id = (
83+
(timestamp << self.TIMESTAMP_SHIFT) |
84+
(self.worker_id << self.WORKER_SHIFT) |
85+
self.sequence
86+
)
87+
88+
return snowflake_id
89+
90+
91+
# Global snowflake generator
92+
_snowflake_gen = SnowflakeGenerator()
93+
94+
95+
def get_git_sha():
96+
"""Get the current git commit SHA."""
97+
try:
98+
return subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("ASCII").strip()
99+
except subprocess.CalledProcessError:
100+
return None
101+
102+
103+
def get_git_branch():
104+
"""Get the current git branch name."""
105+
try:
106+
return (
107+
subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"])
108+
.decode("ASCII")
109+
.strip()
110+
)
111+
except subprocess.CalledProcessError:
112+
return None
113+
114+
115+
def get_run_id():
116+
"""Get or generate the run ID for this test session."""
117+
global _run_id
118+
with _run_id_lock:
119+
if _run_id is None:
120+
_run_id = _snowflake_gen.generate()
121+
return _run_id
122+
123+
124+
def set_run_id(run_id):
125+
"""Set the run ID (used by workers to inherit from main process)."""
126+
global _run_id
127+
with _run_id_lock:
128+
_run_id = run_id
129+
130+
131+
def get_base_result():
132+
"""Collect base result information from environment and git."""
133+
github_sha = get_git_sha()
134+
github_ref_name = get_git_branch()
135+
github_run_id = os.environ.get("GITHUB_RUN_ID", None)
136+
run_number = os.environ.get("GITHUB_RUN_NUMBER", None)
137+
138+
return {
139+
"run_id": get_run_id(),
140+
"github_repository": os.environ.get("GITHUB_REPOSITORY", None),
141+
"github_sha": os.environ.get("GITHUB_SHA", github_sha),
142+
"github_ref": os.environ.get("GITHUB_REF", None),
143+
"github_ref_name": github_ref_name,
144+
"github_run_id": int(github_run_id) if github_run_id else None,
145+
"github_head_ref": os.environ.get("GITHUB_HEAD_REF", None),
146+
"github_run_number": int(run_number) if run_number else None,
147+
"github_base_ref": os.environ.get("GITHUB_BASE_REF", None),
148+
"github_run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT", None),
149+
}
150+
151+
152+
def pytest_configure(config):
153+
"""Generate a unique run ID when pytest starts."""
154+
# Generate the run ID early so it's available for all tests
155+
get_run_id()
156+
157+
158+
def pytest_report_header(config):
159+
"""Add run ID to pytest header."""
160+
run_id = get_run_id()
161+
return f"Run ID: {run_id}"
162+
163+
164+
def pytest_configure_node(node):
165+
"""
166+
Configure worker nodes to inherit the run ID from the main process.
167+
168+
This hook is called by pytest-xdist to configure worker nodes.
169+
"""
170+
node.workerinput["trackflaky_run_id"] = get_run_id()
171+
172+
173+
@pytest.hookimpl(tryfirst=True)
174+
def pytest_sessionstart(session):
175+
"""
176+
Initialize run ID from worker input if this is a worker process.
177+
178+
This runs on worker nodes to receive the run ID from the main process.
179+
"""
180+
if hasattr(session.config, "workerinput"):
181+
# We're in a worker process
182+
workerinput = session.config.workerinput
183+
if "trackflaky_run_id" in workerinput:
184+
set_run_id(workerinput["trackflaky_run_id"])
185+
186+
187+
@pytest.hookimpl(hookwrapper=True)
188+
def pytest_pyfunc_call(pyfuncitem):
189+
"""Hook into pytest test execution to track test outcomes."""
190+
server = os.environ.get("CI_SERVER_URL", None)
191+
192+
result = get_base_result()
193+
result["testname"] = pyfuncitem.name
194+
result["start_time"] = int(time())
195+
196+
outcome = yield
197+
198+
result["end_time"] = int(time())
199+
200+
if outcome.excinfo is None:
201+
result["outcome"] = "success"
202+
elif outcome.excinfo[0] == unittest.case.SkipTest:
203+
result["outcome"] = "skip"
204+
else:
205+
result["outcome"] = "fail"
206+
207+
print(result)
208+
209+
if not server:
210+
return
211+
212+
try:
213+
req = request.Request(f"{server}/hook/test", method="POST")
214+
req.add_header("Content-Type", "application/json")
215+
216+
request.urlopen(
217+
req,
218+
data=json.dumps(result).encode("ASCII"),
219+
)
220+
except ConnectionError as e:
221+
print(f"Could not report testrun: {e}")
222+
except Exception as e:
223+
import warnings
224+
225+
warnings.warn(f"Error reporting testrun: {e}")

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ dependencies = [
1515
"pyln-client",
1616
"pyln-proto",
1717
"pyln-grpc-proto",
18+
"pytest-trackflaky",
19+
"pyln-testing",
1820
]
1921
package-mode = false
2022
[dependency-groups]
@@ -54,15 +56,17 @@ members = [
5456
"contrib/pyln-spec/bolt2",
5557
"contrib/pyln-spec/bolt4",
5658
"contrib/pyln-spec/bolt7",
59+
"contrib/pytest-trackflaky",
5760
]
5861

5962
[tool.uv.sources]
6063
pyln-client = { workspace = true }
6164
pyln-proto = { workspace = true }
6265
pyln-grpc-proto = { workspace = true }
6366
wss-proxy = { workspace = true }
64-
pyln-testing = { workspace = true }
6567
pyln-bolt1 = { workspace = true }
6668
pyln-bolt2 = { workspace = true }
6769
pyln-bolt4 = { workspace = true }
6870
pyln-bolt7 = { workspace = true }
71+
pytest-trackflaky = { workspace = true }
72+
pyln-testing = { workspace = true }

uv.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)