Skip to content

Commit 37286ef

Browse files
dev-ankitclaude
andcommitted
Add zip file support for input paths
- Automatically extract .zip files to a temp directory when provided as input - Handle both single-directory and multi-file zip archives - Temp directories are cleaned up on program exit - Added 13 new tests for zip functionality Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9ffda1d commit 37286ef

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

compare_runs.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,33 @@
11
#!/usr/bin/env python3
22
import argparse
3+
import atexit
34
import csv
45
import json
6+
import tempfile
7+
import zipfile
58
from dataclasses import dataclass
69
from pathlib import Path
710
from typing import Dict, List, Optional, Tuple
811
import re
912
import html as htmllib
1013
from datetime import datetime
1114
from urllib.parse import parse_qsl
15+
import shutil
16+
17+
# Track temporary directories for cleanup
18+
_temp_dirs: List[str] = []
19+
20+
21+
def _cleanup_temp_dirs():
22+
"""Clean up temporary directories created for zip extraction."""
23+
for d in _temp_dirs:
24+
try:
25+
shutil.rmtree(d)
26+
except Exception:
27+
pass
28+
29+
30+
atexit.register(_cleanup_temp_dirs)
1231

1332

1433
NUMERIC_FIELDS = {
@@ -56,6 +75,37 @@ def _as_float(value: str) -> Optional[float]:
5675
return None
5776

5877

78+
def _resolve_path(path: Path) -> Path:
79+
"""Resolve a path, extracting zip files to a temporary directory if needed.
80+
81+
If `path` is a zip file, extracts it to a temporary directory and returns
82+
the path to the extracted contents. The temporary directory is automatically
83+
cleaned up when the program exits.
84+
85+
If `path` is not a zip file, returns it unchanged.
86+
"""
87+
if path.is_file() and path.suffix.lower() == ".zip":
88+
if not zipfile.is_zipfile(path):
89+
raise ValueError(f"File has .zip extension but is not a valid zip file: {path}")
90+
91+
tmpdir = tempfile.mkdtemp(prefix="locust-compare-")
92+
_temp_dirs.append(tmpdir)
93+
94+
with zipfile.ZipFile(path, "r") as zf:
95+
zf.extractall(tmpdir)
96+
97+
extracted = Path(tmpdir)
98+
99+
# If the zip contains a single directory, use that as the root
100+
contents = list(extracted.iterdir())
101+
if len(contents) == 1 and contents[0].is_dir():
102+
return contents[0]
103+
104+
return extracted
105+
106+
return path
107+
108+
59109
def load_report(path: Path) -> List[Row]:
60110
"""Load a Locust report.csv and return parsed rows.
61111
@@ -388,6 +438,10 @@ def compare_reports(
388438
colorize: bool = False,
389439
show_verdict: bool = True,
390440
) -> int:
441+
# Resolve paths (extract zip files if needed)
442+
base_path = _resolve_path(base_path)
443+
curr_path = _resolve_path(curr_path)
444+
391445
base_rows = load_report(base_path)
392446
curr_rows = load_report(curr_path)
393447

tests/test_zip_support.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Tests for zip file support."""
2+
import pytest
3+
import sys
4+
import zipfile
5+
import tempfile
6+
from pathlib import Path
7+
8+
sys.path.insert(0, str(Path(__file__).parent.parent))
9+
from compare_runs import _resolve_path, compare_reports, _temp_dirs
10+
11+
12+
class TestResolvePath:
13+
"""Tests for _resolve_path function."""
14+
15+
def test_regular_directory_unchanged(self, temp_test_dir):
16+
"""Regular directories should be returned unchanged."""
17+
result = _resolve_path(temp_test_dir)
18+
assert result == temp_test_dir
19+
20+
def test_regular_file_unchanged(self, temp_test_dir):
21+
"""Regular files should be returned unchanged."""
22+
csv_path = temp_test_dir / "report.csv"
23+
result = _resolve_path(csv_path)
24+
assert result == csv_path
25+
26+
def test_zip_file_extraction(self, sample_csv_content):
27+
"""Zip files should be extracted to a temp directory."""
28+
with tempfile.TemporaryDirectory() as tmpdir:
29+
# Create a zip file with report.csv
30+
zip_path = Path(tmpdir) / "report.zip"
31+
with zipfile.ZipFile(zip_path, "w") as zf:
32+
zf.writestr("report.csv", sample_csv_content)
33+
34+
result = _resolve_path(zip_path)
35+
36+
# Result should be a different path (extracted)
37+
assert result != zip_path
38+
assert result.is_dir()
39+
# Should contain the extracted file
40+
assert (result / "report.csv").exists()
41+
42+
def test_zip_with_single_directory(self, sample_csv_content):
43+
"""Zip containing a single directory should return that directory."""
44+
with tempfile.TemporaryDirectory() as tmpdir:
45+
zip_path = Path(tmpdir) / "report.zip"
46+
with zipfile.ZipFile(zip_path, "w") as zf:
47+
# Put files inside a subdirectory
48+
zf.writestr("HTML-Report-123/report.csv", sample_csv_content)
49+
50+
result = _resolve_path(zip_path)
51+
52+
# Should return the inner directory
53+
assert result.name == "HTML-Report-123"
54+
assert (result / "report.csv").exists()
55+
56+
def test_zip_with_multiple_files(self, sample_csv_content, sample_html_template_args):
57+
"""Zip with multiple files at root should return the extraction root."""
58+
with tempfile.TemporaryDirectory() as tmpdir:
59+
zip_path = Path(tmpdir) / "report.zip"
60+
with zipfile.ZipFile(zip_path, "w") as zf:
61+
zf.writestr("report.csv", sample_csv_content)
62+
zf.writestr("feature.html", sample_html_template_args)
63+
64+
result = _resolve_path(zip_path)
65+
66+
# Should return the temp directory root
67+
assert (result / "report.csv").exists()
68+
assert (result / "feature.html").exists()
69+
70+
def test_invalid_zip_raises_error(self):
71+
"""Files with .zip extension that aren't valid zips should raise."""
72+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
73+
f.write(b"not a zip file")
74+
f.flush()
75+
76+
with pytest.raises(ValueError, match="not a valid zip file"):
77+
_resolve_path(Path(f.name))
78+
79+
def test_nonexistent_path_unchanged(self):
80+
"""Non-existent paths should be returned unchanged (let load_report handle error)."""
81+
path = Path("/nonexistent/path")
82+
result = _resolve_path(path)
83+
assert result == path
84+
85+
def test_temp_dirs_tracked_for_cleanup(self, sample_csv_content):
86+
"""Extracted temp directories should be tracked for cleanup."""
87+
initial_count = len(_temp_dirs)
88+
89+
with tempfile.TemporaryDirectory() as tmpdir:
90+
zip_path = Path(tmpdir) / "report.zip"
91+
with zipfile.ZipFile(zip_path, "w") as zf:
92+
zf.writestr("report.csv", sample_csv_content)
93+
94+
_resolve_path(zip_path)
95+
96+
# Should have added a temp dir
97+
assert len(_temp_dirs) > initial_count
98+
99+
100+
class TestCompareReportsWithZip:
101+
"""Tests for compare_reports with zip file input."""
102+
103+
def test_compare_zip_to_zip(self, sample_csv_content, sample_csv_content_v2, capsys):
104+
"""Should be able to compare two zip files."""
105+
with tempfile.TemporaryDirectory() as tmpdir:
106+
base_zip = Path(tmpdir) / "base.zip"
107+
curr_zip = Path(tmpdir) / "current.zip"
108+
109+
with zipfile.ZipFile(base_zip, "w") as zf:
110+
zf.writestr("report.csv", sample_csv_content)
111+
112+
with zipfile.ZipFile(curr_zip, "w") as zf:
113+
zf.writestr("report.csv", sample_csv_content_v2)
114+
115+
result = compare_reports(base_zip, curr_zip, as_json=True)
116+
assert result == 0
117+
118+
captured = capsys.readouterr()
119+
assert "/api/users" in captured.out
120+
121+
def test_compare_zip_to_directory(self, sample_csv_content, temp_test_dir_v2, capsys):
122+
"""Should be able to compare a zip file to a directory."""
123+
with tempfile.TemporaryDirectory() as tmpdir:
124+
base_zip = Path(tmpdir) / "base.zip"
125+
126+
with zipfile.ZipFile(base_zip, "w") as zf:
127+
zf.writestr("report.csv", sample_csv_content)
128+
129+
result = compare_reports(base_zip, temp_test_dir_v2, as_json=True)
130+
assert result == 0
131+
132+
def test_compare_directory_to_zip(self, temp_test_dir, sample_csv_content_v2, capsys):
133+
"""Should be able to compare a directory to a zip file."""
134+
with tempfile.TemporaryDirectory() as tmpdir:
135+
curr_zip = Path(tmpdir) / "current.zip"
136+
137+
with zipfile.ZipFile(curr_zip, "w") as zf:
138+
zf.writestr("report.csv", sample_csv_content_v2)
139+
140+
result = compare_reports(temp_test_dir, curr_zip, as_json=True)
141+
assert result == 0
142+
143+
def test_zip_with_nested_directory_structure(self, sample_csv_content, sample_csv_content_v2, capsys):
144+
"""Zip with report inside a subdirectory should work."""
145+
with tempfile.TemporaryDirectory() as tmpdir:
146+
base_zip = Path(tmpdir) / "base.zip"
147+
curr_zip = Path(tmpdir) / "current.zip"
148+
149+
with zipfile.ZipFile(base_zip, "w") as zf:
150+
zf.writestr("HTML-Report-100/report.csv", sample_csv_content)
151+
152+
with zipfile.ZipFile(curr_zip, "w") as zf:
153+
zf.writestr("HTML-Report-200/report.csv", sample_csv_content_v2)
154+
155+
result = compare_reports(base_zip, curr_zip, as_json=True)
156+
assert result == 0
157+
158+
def test_zip_with_html_files(self, sample_csv_content, sample_html_template_args, capsys):
159+
"""Zip containing HTML feature files should be parsed."""
160+
with tempfile.TemporaryDirectory() as tmpdir:
161+
base_zip = Path(tmpdir) / "base.zip"
162+
curr_zip = Path(tmpdir) / "current.zip"
163+
164+
with zipfile.ZipFile(base_zip, "w") as zf:
165+
zf.writestr("report.csv", sample_csv_content)
166+
zf.writestr("feature_test.html", sample_html_template_args)
167+
168+
with zipfile.ZipFile(curr_zip, "w") as zf:
169+
zf.writestr("report.csv", sample_csv_content)
170+
zf.writestr("feature_test.html", sample_html_template_args)
171+
172+
result = compare_reports(base_zip, curr_zip, as_json=True)
173+
assert result == 0
174+
175+
captured = capsys.readouterr()
176+
# Should include HTML features
177+
assert "HTML:feature_test" in captured.out

0 commit comments

Comments
 (0)