junit-time-diff 0.1.0__py3-none-any.whl

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,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: junit-time-diff
3
+ Version: 0.1.0
4
+ Summary: Compare test execution times from JUnit XML reports.
5
+ Author-email: Mauricio Villegas <mauricio@omnius.com>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=8.3.5; extra == "test"
17
+ Requires-Dist: pytest-cov>=6.1.1; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # junit-time-diff
21
+
22
+ `junit-time-diff` compares test execution times from JUnit XML reports and highlights meaningful slowdowns, speedups, new tests, and removed tests.
23
+
24
+ It works especially well with pytest's built-in `--junit-xml` output and can compare either single runs or averages computed from multiple XML files.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install junit-time-diff
30
+ ```
31
+
32
+ For local development from this repository:
33
+
34
+ ```bash
35
+ pip install -e .
36
+ ```
37
+
38
+ This installs the `junit-time-diff` command.
39
+
40
+ ## Quick Start
41
+
42
+ ### Single Run Comparison
43
+
44
+ ```bash
45
+ pytest --junit-xml=baseline.xml
46
+
47
+ # make changes
48
+
49
+ pytest --junit-xml=current.xml
50
+
51
+ junit-time-diff baseline.xml current.xml
52
+ ```
53
+
54
+ ### Averaged Comparison Across Multiple Runs
55
+
56
+ ```bash
57
+ for i in {1..5}; do pytest --junit-xml=baseline$i.xml; done
58
+
59
+ # make changes
60
+
61
+ for i in {1..5}; do pytest --junit-xml=current$i.xml; done
62
+
63
+ junit-time-diff "baseline*.xml" "current*.xml"
64
+ ```
65
+
66
+ Quoting the glob pattern is recommended so the tool receives the pattern and expands it consistently.
67
+
68
+ ## CLI Usage
69
+
70
+ ```bash
71
+ junit-time-diff --help
72
+ ```
73
+
74
+ ```text
75
+ usage: junit-time-diff [-h] [--threshold THRESHOLD] [--min-diff MIN_DIFF]
76
+ baseline current
77
+ ```
78
+
79
+ Arguments:
80
+
81
+ - `baseline`: Baseline JUnit XML file or glob pattern such as `baseline.xml` or `baseline*.xml`
82
+ - `current`: Current JUnit XML file or glob pattern such as `current.xml` or `current*.xml`
83
+
84
+ Options:
85
+
86
+ - `--threshold`: Ratio threshold for reporting changes, default `1.10`
87
+ - `--min-diff`: Minimum absolute duration change in seconds, default `0.01`
88
+
89
+ ## Example Output
90
+
91
+ ```text
92
+ ================================================================================
93
+ TEST TIMING COMPARISON REPORT
94
+ ================================================================================
95
+
96
+ SUMMARY
97
+ --------------------------------------------------------------------------------
98
+ Baseline: 9 tests, 3.64s total
99
+ Current: 9 tests, 3.69s total
100
+ Difference: +0.05s (+1.3%)
101
+ New tests: 1, Removed tests: 1
102
+ ```
103
+
104
+ The report then includes any significant slower tests, faster tests, new tests, removed tests, and a final verdict.
105
+
106
+ ## Typical Workflows
107
+
108
+ ### Compare Python Versions
109
+
110
+ ```bash
111
+ python3.11 -m pytest --junit-xml=py311.xml
112
+ python3.12 -m pytest --junit-xml=py312.xml
113
+
114
+ junit-time-diff py311.xml py312.xml
115
+ ```
116
+
117
+ ### Compare a Specific Test Subset
118
+
119
+ ```bash
120
+ pytest tests/test_api.py --junit-xml=baseline_api.xml
121
+
122
+ # make changes
123
+
124
+ pytest tests/test_api.py --junit-xml=current_api.xml
125
+
126
+ junit-time-diff baseline_api.xml current_api.xml
127
+ ```
128
+
129
+ ### Use a Stricter Threshold
130
+
131
+ ```bash
132
+ junit-time-diff baseline.xml current.xml --threshold 1.05
133
+ ```
134
+
135
+ ## Tips
136
+
137
+ - Run several repetitions and compare averages to reduce noise.
138
+ - Compare results on the same machine when possible.
139
+ - Keep the selected test set consistent between baseline and current runs.
140
+ - Ignore tiny changes unless they are part of a repeated pattern.
141
+
142
+ ## Development
143
+
144
+ Build the package:
145
+
146
+ ```bash
147
+ python3 -m build
148
+ ```
149
+
150
+ Run tests:
151
+
152
+ ```bash
153
+ pytest
154
+ ```
@@ -0,0 +1,7 @@
1
+ junit_time_diff.py,sha256=yaKoU0Kz-t0KlMBNNql7f7ykestJFfP435tZhFP0nn4,13207
2
+ junit_time_diff-0.1.0.dist-info/licenses/LICENSE,sha256=dz9IJLqyJ8GH5ihpx6XMeXLMvkW7wuryN1S6MqVB_KQ,1115
3
+ junit_time_diff-0.1.0.dist-info/METADATA,sha256=elZmLbxmxcseF74uHRRHAiXg-oxD8_0ACXZiahk-61k,3644
4
+ junit_time_diff-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ junit_time_diff-0.1.0.dist-info/entry_points.txt,sha256=5d1hgSazKUjZlx5D3S3lDtrxLx-xRQi15ksH4megL4M,57
6
+ junit_time_diff-0.1.0.dist-info/top_level.txt,sha256=2CWY8czXC_uo09EFM2kdHzaP_U1teWQeng_Klh2hhkM,16
7
+ junit_time_diff-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ junit-time-diff = junit_time_diff:main
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026-present, Mauricio Villegas <mauricio@omnius.com>
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 @@
1
+ junit_time_diff
junit_time_diff.py ADDED
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env python3
2
+ """Compare test execution times from JUnit XML reports."""
3
+
4
+ import argparse
5
+ import glob
6
+ import statistics
7
+ import sys
8
+ import xml.etree.ElementTree as ET
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ __version__ = "0.1.0"
13
+
14
+
15
+ def load_junit_timings(filepath: Path) -> dict[str, Any]:
16
+ """Load timing data from a JUnit XML file.
17
+
18
+ Args:
19
+ filepath: Path to the JUnit XML file.
20
+
21
+ Returns:
22
+ Dictionary with 'total_duration', 'test_count', and 'tests' keys.
23
+ """
24
+ tree = ET.parse(filepath)
25
+ root = tree.getroot()
26
+
27
+ tests: dict[str, dict[str, Any]] = {}
28
+ total_duration = 0.0
29
+
30
+ # Handle both <testsuites> wrapper and direct <testsuite>
31
+ testsuites = root.findall(".//testsuite")
32
+ if not testsuites:
33
+ testsuites = [root] if root.tag == "testsuite" else []
34
+
35
+ for testsuite in testsuites:
36
+ for testcase in testsuite.findall("testcase"):
37
+ classname = testcase.get("classname", "")
38
+ name = testcase.get("name", "")
39
+ time_str = testcase.get("time", "0")
40
+
41
+ # Create unique test ID
42
+ test_id = f"{classname}::{name}"
43
+
44
+ # Parse time
45
+ try:
46
+ duration = float(time_str)
47
+ except (TypeError, ValueError):
48
+ duration = 0.0
49
+
50
+ # Determine outcome
51
+ if testcase.find("failure") is not None:
52
+ outcome = "failed"
53
+ elif testcase.find("error") is not None:
54
+ outcome = "error"
55
+ elif testcase.find("skipped") is not None:
56
+ outcome = "skipped"
57
+ else:
58
+ outcome = "passed"
59
+
60
+ tests[test_id] = {
61
+ "duration": duration,
62
+ "outcome": outcome,
63
+ }
64
+ total_duration += duration
65
+
66
+ return {
67
+ "total_duration": total_duration,
68
+ "test_count": len(tests),
69
+ "tests": tests,
70
+ }
71
+
72
+
73
+ def load_and_average_timings(pattern: str) -> dict[str, Any]:
74
+ """Load timing data from one or more JUnit XML files and average durations.
75
+
76
+ Args:
77
+ pattern: File pattern to match (e.g., "baseline*.xml" or "baseline.xml")
78
+
79
+ Returns:
80
+ Dictionary with averaged timing data.
81
+ """
82
+ # Find all matching files
83
+ files = sorted(glob.glob(pattern))
84
+
85
+ if not files:
86
+ raise FileNotFoundError(f"No files match pattern: {pattern}")
87
+
88
+ print(f"Loading {len(files)} file(s) matching '{pattern}':")
89
+ for file in files:
90
+ print(f" - {file}")
91
+
92
+ # Load all timing data
93
+ all_timings = [load_junit_timings(Path(file)) for file in files]
94
+
95
+ if len(all_timings) == 1:
96
+ # Single file, return as-is
97
+ return all_timings[0]
98
+
99
+ # Collect all test IDs across all runs
100
+ all_test_ids = set()
101
+ for timing in all_timings:
102
+ all_test_ids.update(timing["tests"].keys())
103
+
104
+ # Calculate average duration for each test
105
+ averaged_tests: dict[str, dict[str, Any]] = {}
106
+ for test_id in all_test_ids:
107
+ durations = []
108
+ outcomes = []
109
+
110
+ for timing in all_timings:
111
+ if test_id in timing["tests"]:
112
+ durations.append(timing["tests"][test_id]["duration"])
113
+ outcomes.append(timing["tests"][test_id]["outcome"])
114
+
115
+ # Average duration
116
+ avg_duration = statistics.mean(durations) if durations else 0.0
117
+
118
+ # Most common outcome (or first if tied)
119
+ outcome = max(set(outcomes), key=outcomes.count) if outcomes else "unknown"
120
+
121
+ averaged_tests[test_id] = {
122
+ "duration": avg_duration,
123
+ "outcome": outcome,
124
+ "runs": len(durations), # Track how many runs included this test
125
+ }
126
+
127
+ # Calculate total duration
128
+ total_duration = sum(test["duration"] for test in averaged_tests.values())
129
+
130
+ return {
131
+ "total_duration": total_duration,
132
+ "test_count": len(averaged_tests),
133
+ "tests": averaged_tests,
134
+ "num_runs": len(all_timings),
135
+ }
136
+
137
+
138
+ def compare_timings(
139
+ baseline: dict[str, Any],
140
+ current: dict[str, Any],
141
+ threshold: float = 1.10,
142
+ min_threshold_seconds: float = 0.01,
143
+ ) -> int:
144
+ """Compare timing data and report significant differences.
145
+
146
+ Args:
147
+ baseline: Baseline timing data (before changes)
148
+ current: Current timing data (after changes)
149
+ threshold: Percentage threshold for reporting (e.g., 1.10 = 10% slower)
150
+ min_threshold_seconds: Minimum absolute time difference to report (in seconds)
151
+
152
+ Returns:
153
+ Exit code (0 = no issues, 1 = significant slowdown detected)
154
+ """
155
+ baseline_tests = baseline["tests"]
156
+ current_tests = current["tests"]
157
+
158
+ # Find common tests
159
+ common_tests = set(baseline_tests.keys()) & set(current_tests.keys())
160
+ new_tests = set(current_tests.keys()) - set(baseline_tests.keys())
161
+ removed_tests = set(baseline_tests.keys()) - set(current_tests.keys())
162
+
163
+ # Calculate total time changes
164
+ baseline_total = baseline["total_duration"]
165
+ current_total = current["total_duration"]
166
+ total_diff = current_total - baseline_total
167
+ total_percent = (current_total / baseline_total - 1) * 100 if baseline_total > 0 else 0.0
168
+
169
+ print("=" * 80)
170
+ print("TEST TIMING COMPARISON REPORT")
171
+ print("=" * 80)
172
+ print()
173
+
174
+ # Summary
175
+ print("SUMMARY")
176
+ print("-" * 80)
177
+ baseline_runs = baseline.get("num_runs", 1)
178
+ current_runs = current.get("num_runs", 1)
179
+ if baseline_runs > 1 or current_runs > 1:
180
+ print(
181
+ f"Baseline: {baseline['test_count']} tests, {baseline_total:.2f}s total "
182
+ f"(averaged over {baseline_runs} run(s))"
183
+ )
184
+ print(
185
+ f"Current: {current['test_count']} tests, {current_total:.2f}s total "
186
+ f"(averaged over {current_runs} run(s))"
187
+ )
188
+ else:
189
+ print(f"Baseline: {baseline['test_count']} tests, {baseline_total:.2f}s total")
190
+ print(f"Current: {current['test_count']} tests, {current_total:.2f}s total")
191
+ print(f"Difference: {total_diff:+.2f}s ({total_percent:+.1f}%)")
192
+ print(f"New tests: {len(new_tests)}, Removed tests: {len(removed_tests)}")
193
+ print()
194
+
195
+ # Analyze common tests
196
+ slower = []
197
+ faster = []
198
+
199
+ for test_id in common_tests:
200
+ baseline_duration = baseline_tests[test_id]["duration"]
201
+ current_duration = current_tests[test_id]["duration"]
202
+ diff = current_duration - baseline_duration
203
+
204
+ if baseline_duration > 0:
205
+ ratio = current_duration / baseline_duration
206
+ percent_change = (ratio - 1) * 100
207
+ else:
208
+ ratio = float("inf") if current_duration > 0 else 1.0
209
+ percent_change = 0.0
210
+
211
+ # Check if difference is significant
212
+ is_significant = (ratio >= threshold and diff >= min_threshold_seconds) or (
213
+ ratio <= (2 - threshold) and abs(diff) >= min_threshold_seconds
214
+ )
215
+ if not is_significant:
216
+ continue
217
+
218
+ test_info = {
219
+ "id": test_id,
220
+ "baseline": baseline_duration,
221
+ "current": current_duration,
222
+ "diff": diff,
223
+ "percent": percent_change,
224
+ }
225
+ if diff > 0:
226
+ slower.append(test_info)
227
+ else:
228
+ faster.append(test_info)
229
+
230
+ # Report slower tests
231
+ if slower:
232
+ slower.sort(key=lambda item: abs(item["diff"]), reverse=True)
233
+
234
+ print(f"SLOWER TESTS ({len(slower)} tests)")
235
+ print("-" * 80)
236
+ print(f"{'Test':<60} {'Before':>10} {'After':>10} {'Diff':>10} {'Change':>8}")
237
+ print("-" * 80)
238
+
239
+ for test in slower[:20]: # Show top 20
240
+ test_name = test["id"]
241
+ if len(test_name) > 60:
242
+ test_name = "..." + test_name[-57:]
243
+
244
+ print(
245
+ f"{test_name:<60} "
246
+ f"{test['baseline']:>9.3f}s "
247
+ f"{test['current']:>9.3f}s "
248
+ f"{test['diff']:>+9.3f}s "
249
+ f"{test['percent']:>+7.1f}%"
250
+ )
251
+
252
+ if len(slower) > 20:
253
+ print(f"... and {len(slower) - 20} more slower tests")
254
+ print()
255
+
256
+ # Report faster tests
257
+ if faster:
258
+ faster.sort(key=lambda item: abs(item["diff"]), reverse=True)
259
+
260
+ print(f"FASTER TESTS ({len(faster)} tests)")
261
+ print("-" * 80)
262
+ print(f"{'Test':<60} {'Before':>10} {'After':>10} {'Diff':>10} {'Change':>8}")
263
+ print("-" * 80)
264
+
265
+ for test in faster[:20]: # Show top 20
266
+ test_name = test["id"]
267
+ if len(test_name) > 60:
268
+ test_name = "..." + test_name[-57:]
269
+
270
+ print(
271
+ f"{test_name:<60} "
272
+ f"{test['baseline']:>9.3f}s "
273
+ f"{test['current']:>9.3f}s "
274
+ f"{test['diff']:>+9.3f}s "
275
+ f"{test['percent']:>+7.1f}%"
276
+ )
277
+
278
+ if len(faster) > 20:
279
+ print(f"... and {len(faster) - 20} more faster tests")
280
+ print()
281
+
282
+ # Report new tests
283
+ if new_tests:
284
+ new_test_list = sorted(
285
+ ((test_id, current_tests[test_id]["duration"]) for test_id in new_tests),
286
+ key=lambda item: item[1],
287
+ reverse=True,
288
+ )
289
+ new_tests_total = sum(duration for _, duration in new_test_list)
290
+ print(f"NEW TESTS ({len(new_tests)} tests, {new_tests_total:.2f}s total)")
291
+ print("-" * 80)
292
+
293
+ for test_id, duration in new_test_list[:10]: # Show top 10
294
+ test_name = test_id
295
+ if len(test_name) > 60:
296
+ test_name = "..." + test_name[-57:]
297
+ print(f"{test_name:<60} {duration:>9.3f}s")
298
+
299
+ if len(new_tests) > 10:
300
+ print(f"... and {len(new_tests) - 10} more new tests")
301
+ print()
302
+
303
+ # Report removed tests
304
+ if removed_tests:
305
+ print(f"REMOVED TESTS ({len(removed_tests)} tests)")
306
+ print("-" * 80)
307
+ for test_id in sorted(removed_tests)[:10]: # Show top 10
308
+ test_name = test_id
309
+ if len(test_name) > 70:
310
+ test_name = "..." + test_name[-67:]
311
+ print(f" {test_name}")
312
+
313
+ if len(removed_tests) > 10:
314
+ print(f"... and {len(removed_tests) - 10} more removed tests")
315
+ print()
316
+
317
+ # Final verdict
318
+ print("=" * 80)
319
+ print("VERDICT")
320
+ print("-" * 80)
321
+
322
+ if not slower:
323
+ print("✓ No significant performance regressions detected!")
324
+ return_code = 0
325
+ elif total_percent > 5:
326
+ print(f"⚠ WARNING: Overall test suite is {total_percent:.1f}% slower!")
327
+ print(f" {len(slower)} tests got slower (threshold: {(threshold - 1) * 100:.0f}%)")
328
+ return_code = 1
329
+ else:
330
+ print(f"ℹ INFO: Some tests got slower, but overall impact is {total_percent:+.1f}%")
331
+ print(f" {len(slower)} tests got slower (threshold: {(threshold - 1) * 100:.0f}%)")
332
+ return_code = 0
333
+
334
+ if faster:
335
+ print(f"✓ {len(faster)} tests got faster!")
336
+
337
+ print("=" * 80)
338
+ return return_code
339
+
340
+
341
+ def build_parser() -> argparse.ArgumentParser:
342
+ """Create the CLI argument parser."""
343
+ parser = argparse.ArgumentParser(
344
+ prog="junit-time-diff",
345
+ description="Compare test execution times from JUnit XML reports.",
346
+ formatter_class=argparse.RawDescriptionHelpFormatter,
347
+ epilog="""
348
+ Examples:
349
+ # Compare single baseline with current run
350
+ %(prog)s baseline.xml current.xml
351
+
352
+ # Compare averaged results from multiple runs
353
+ %(prog)s "baseline*.xml" "current*.xml"
354
+
355
+ # Use custom threshold (15%% instead of default 10%%)
356
+ %(prog)s baseline.xml current.xml --threshold 1.15
357
+
358
+ # Ignore differences less than 0.05 seconds
359
+ %(prog)s baseline.xml current.xml --min-diff 0.05
360
+
361
+ Generate JUnit XML with pytest:
362
+ # Single run
363
+ pytest --junit-xml=baseline.xml
364
+
365
+ # Multiple runs (reduces timing variance)
366
+ for i in {{1..5}}; do pytest --junit-xml=baseline$i.xml; done
367
+ for i in {{1..5}}; do pytest --junit-xml=current$i.xml; done
368
+ %(prog)s "baseline*.xml" "current*.xml"
369
+ """.strip(),
370
+ )
371
+ parser.add_argument(
372
+ "baseline",
373
+ help="Baseline JUnit XML file or pattern (for example 'baseline.xml' or 'baseline*.xml').",
374
+ )
375
+ parser.add_argument(
376
+ "current",
377
+ help="Current JUnit XML file or pattern (for example 'current.xml' or 'current*.xml').",
378
+ )
379
+ parser.add_argument(
380
+ "--threshold",
381
+ type=float,
382
+ default=1.10,
383
+ help="Ratio threshold for reporting significant changes (default: 1.10 = 10%% slower).",
384
+ )
385
+ parser.add_argument(
386
+ "--min-diff",
387
+ type=float,
388
+ default=0.01,
389
+ help="Minimum absolute time difference in seconds to report (default: 0.01).",
390
+ )
391
+ return parser
392
+
393
+
394
+ def main(argv: list[str] | None = None) -> int:
395
+ """Run the command-line interface."""
396
+ parser = build_parser()
397
+ args = parser.parse_args(argv)
398
+
399
+ try:
400
+ print()
401
+ baseline = load_and_average_timings(args.baseline)
402
+ print()
403
+ current = load_and_average_timings(args.current)
404
+ print()
405
+ return compare_timings(baseline, current, args.threshold, args.min_diff)
406
+ except Exception as ex:
407
+ print(f"Error: {ex}", file=sys.stderr)
408
+ return 1
409
+
410
+
411
+ if __name__ == "__main__":
412
+ sys.exit(main())