|
8 | 8 | from enum import StrEnum |
9 | 9 | from pathlib import Path |
10 | 10 |
|
| 11 | +import anyio |
11 | 12 | from dotenv import load_dotenv |
12 | 13 | from loguru import logger |
13 | 14 |
|
@@ -69,6 +70,16 @@ class Fixture(StrEnum): |
69 | 70 |
|
70 | 71 | load_dotenv() |
71 | 72 |
|
| 73 | +_fixture_locks: dict[Fixture, anyio.Lock] = {} |
| 74 | + |
| 75 | + |
| 76 | +def _fixture_lock(category: Fixture) -> anyio.Lock: |
| 77 | + lock = _fixture_locks.get(category) |
| 78 | + if lock is None: |
| 79 | + lock = anyio.Lock() |
| 80 | + _fixture_locks[category] = lock |
| 81 | + return lock |
| 82 | + |
72 | 83 |
|
73 | 84 | async def download_fixture(category: Fixture) -> Path: |
74 | 85 | """Download and cache a filesystem test fixture. |
@@ -97,69 +108,70 @@ async def download_fixture(category: Fixture) -> Path: |
97 | 108 | cache_dir = Path(user_cache_dir("mcp-evals", "mcp-evals")) / "fixtures" |
98 | 109 | fixture_path = cache_dir / category |
99 | 110 |
|
100 | | - # Return cached fixture if it exists |
101 | | - if fixture_path.exists() and fixture_path.is_dir(): |
102 | | - logger.debug(f"Using cached fixture '{category.value}'") |
103 | | - return fixture_path |
| 111 | + async with _fixture_lock(category): |
| 112 | + # Return cached fixture if it exists |
| 113 | + if fixture_path.exists() and fixture_path.is_dir(): |
| 114 | + logger.debug(f"Using cached fixture '{category.value}'") |
| 115 | + return fixture_path |
104 | 116 |
|
105 | | - # Download fixture |
106 | | - logger.debug(f"Downloading fixture '{category.value}'") |
107 | | - url = FIXTURE_URL_MAPPING[category] |
108 | | - zip_path = cache_dir / f"{category}.zip" |
| 117 | + # Download fixture |
| 118 | + logger.debug(f"Downloading fixture '{category.value}'") |
| 119 | + url = FIXTURE_URL_MAPPING[category] |
| 120 | + zip_path = cache_dir / f"{category}.zip" |
109 | 121 |
|
110 | | - # Ensure cache directory exists |
111 | | - cache_dir.mkdir(parents=True, exist_ok=True) |
| 122 | + # Ensure cache directory exists |
| 123 | + cache_dir.mkdir(parents=True, exist_ok=True) |
112 | 124 |
|
113 | | - try: |
114 | | - # Download using httpx with streaming |
115 | | - timeout = httpx.Timeout(connect=5.0, read=5.0, write=10.0, pool=5.0) |
116 | | - proxy_url = os.getenv("DOWNLOAD_PROXY") |
117 | | - async with ( |
118 | | - httpx.AsyncClient(timeout=timeout, proxy=proxy_url) as client, |
119 | | - client.stream("GET", url, follow_redirects=True) as response, |
120 | | - ): |
121 | | - response.raise_for_status() |
122 | | - total_size = int(response.headers.get("content-length", 0)) or None |
123 | | - async with aiofiles.open(zip_path, "wb") as f: |
124 | | - with tqdm( |
125 | | - total=total_size, |
126 | | - unit="B", |
127 | | - unit_scale=True, |
128 | | - unit_divisor=1024, |
129 | | - desc=f"Downloading {category}", |
130 | | - ) as pbar: |
131 | | - async for chunk in response.aiter_bytes(): |
132 | | - await f.write(chunk) |
133 | | - pbar.update(len(chunk)) |
134 | | - |
135 | | - # Extract ZIP file |
136 | | - with zipfile.ZipFile(zip_path) as zip_file: |
137 | | - zip_file.extractall(cache_dir) |
138 | | - |
139 | | - # Clean up macOS metadata if present |
140 | | - macosx_path = cache_dir / "__MACOSX" |
141 | | - if macosx_path.exists(): |
142 | | - shutil.rmtree(macosx_path) |
143 | | - |
144 | | - # Clean up ZIP file |
145 | | - zip_path.unlink(missing_ok=True) |
146 | | - |
147 | | - except httpx.HTTPError as e: |
148 | | - msg = f"Failed to download fixture from {url}: {e}" |
149 | | - raise RuntimeError(msg) from e |
150 | | - except zipfile.BadZipFile as e: |
151 | | - msg = f"Invalid ZIP file for category {category}: {e}" |
152 | | - raise RuntimeError(msg) from e |
153 | | - except Exception as e: |
154 | | - msg = f"Failed to download or extract fixture for category {category}: {e}" |
155 | | - raise RuntimeError(msg) from e |
| 125 | + try: |
| 126 | + # Download using httpx with streaming |
| 127 | + timeout = httpx.Timeout(connect=5.0, read=5.0, write=10.0, pool=5.0) |
| 128 | + proxy_url = os.getenv("DOWNLOAD_PROXY") |
| 129 | + async with ( |
| 130 | + httpx.AsyncClient(timeout=timeout, proxy=proxy_url) as client, |
| 131 | + client.stream("GET", url, follow_redirects=True) as response, |
| 132 | + ): |
| 133 | + response.raise_for_status() |
| 134 | + total_size = int(response.headers.get("content-length", 0)) or None |
| 135 | + async with aiofiles.open(zip_path, "wb") as f: |
| 136 | + with tqdm( |
| 137 | + total=total_size, |
| 138 | + unit="B", |
| 139 | + unit_scale=True, |
| 140 | + unit_divisor=1024, |
| 141 | + desc=f"Downloading {category}", |
| 142 | + ) as pbar: |
| 143 | + async for chunk in response.aiter_bytes(): |
| 144 | + await f.write(chunk) |
| 145 | + pbar.update(len(chunk)) |
| 146 | + |
| 147 | + # Extract ZIP file |
| 148 | + with zipfile.ZipFile(zip_path) as zip_file: |
| 149 | + zip_file.extractall(cache_dir) |
| 150 | + |
| 151 | + # Clean up macOS metadata if present |
| 152 | + macosx_path = cache_dir / "__MACOSX" |
| 153 | + if macosx_path.exists(): |
| 154 | + shutil.rmtree(macosx_path) |
| 155 | + |
| 156 | + # Clean up ZIP file |
| 157 | + zip_path.unlink(missing_ok=True) |
| 158 | + |
| 159 | + except httpx.HTTPError as e: |
| 160 | + msg = f"Failed to download fixture from {url}: {e}" |
| 161 | + raise RuntimeError(msg) from e |
| 162 | + except zipfile.BadZipFile as e: |
| 163 | + msg = f"Invalid ZIP file for category {category}: {e}" |
| 164 | + raise RuntimeError(msg) from e |
| 165 | + except Exception as e: |
| 166 | + msg = f"Failed to download or extract fixture for category {category}: {e}" |
| 167 | + raise RuntimeError(msg) from e |
156 | 168 |
|
157 | | - # Verify extraction |
158 | | - if not fixture_path.exists(): |
159 | | - msg = f"Extracted directory not found: {fixture_path}" |
160 | | - raise RuntimeError(msg) |
| 169 | + # Verify extraction |
| 170 | + if not fixture_path.exists(): |
| 171 | + msg = f"Extracted directory not found: {fixture_path}" |
| 172 | + raise RuntimeError(msg) |
161 | 173 |
|
162 | | - return fixture_path |
| 174 | + return fixture_path |
163 | 175 |
|
164 | 176 |
|
165 | 177 | @asynccontextmanager |
|
0 commit comments