diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 927d8417e3..e2ca877a8a 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -20,6 +20,23 @@ log = logging.getLogger("bbot.core.helpers.web") +async def iter_batch_results(stream): + """ + Yield individual ``BatchResult`` objects from a ``request_batch_stream`` iterator. + + The native blasthttp 0.4.0 iterator yields lists of ``BatchResult`` (drained in + chunks of 1000 / 200ms to amortize the Python↔Rust boundary). A future Python + wrapper is expected to unwrap these into individual items. This helper handles + both shapes so callers can write a single ``async for`` loop. + """ + async for item in stream: + if isinstance(item, list): + for r in item: + yield r + else: + yield item + + class WebHelper: """ Main utility class for managing HTTP operations in BBOT. Uses blasthttp (Rust) as the @@ -297,23 +314,26 @@ async def request(self, *args, **kwargs): log.trace(traceback.format_exc()) raise - async def request_batch(self, urls, threads=10, **kwargs): + async def request_batch_stream(self, urls, threads=10, **kwargs): """ - Request multiple URLs in parallel via blasthttp's native Rust batch engine. + Request multiple URLs in parallel via blasthttp's native Rust batch engine, + yielding each response as soon as it completes (completion order, not input + order). Applies the same header/cookie/proxy/timeout logic as ``request()`` — each - entry is translated into a ``blasthttp.BatchConfig`` and sent to Rust in one - shot. Results are returned as a list (not streamed). + entry is translated into a ``blasthttp.BatchConfig`` and dispatched through + ``blasthttp.request_batch_stream``. A slow request no longer blocks faster + peers behind it, and Python work overlaps with in-flight HTTP I/O. Each entry in ``urls`` can be: - A plain URL string (uses shared ``**kwargs`` for all requests) - A ``(url, per_request_kwargs)`` tuple for per-request options - A ``(url, per_request_kwargs, tracker)`` tuple to attach arbitrary - tracking data that is returned alongside the response + tracking data that is yielded alongside the response - Returns: - When entries are plain strings: ``list[(url, response)]`` - When any entry includes a tracker: ``list[(url, response, tracker)]`` + Yields: + When entries are plain strings: ``(url, response)`` + When any entry includes a tracker: ``(url, response, tracker)`` Args: urls: URLs to visit — strings or ``(url, kwargs[, tracker])`` tuples. @@ -324,15 +344,13 @@ async def request_batch(self, urls, threads=10, **kwargs): Examples: Simple (shared kwargs):: - results = await self.helpers.request_batch(urls, headers={"X-Test": "Test"}) - for url, response in results: + async for url, response in self.helpers.request_batch_stream(urls, headers={"X-Test": "Test"}): ... Per-request kwargs with tracker:: reqs = [("http://example.com", {"method": "POST"}, "my-tracker")] - results = await self.helpers.request_batch(reqs) - for url, response, tracker in results: + async for url, response, tracker in self.helpers.request_batch_stream(reqs): ... """ import blasthttp @@ -354,33 +372,33 @@ async def request_batch(self, urls, threads=10, **kwargs): entries.append((str(entry), kwargs, None)) if not entries: - return [] + return + + # Build BatchConfig objects using the same logic as request(). + # Map each config URL back to a queue of trackers so we can correlate + # completion-order results to original entries even when multiple entries + # share a URL. + from collections import deque - # Build BatchConfig objects using the same logic as request() configs = [] - trackers = [] + trackers_by_url = {} for url, req_kwargs, tracker in entries: url, method, blast_kwargs = self._build_blasthttp_kwargs(url, **req_kwargs) config = blasthttp.BatchConfig(url, **blast_kwargs) configs.append(config) - trackers.append(tracker) - - # Send to Rust — all I/O happens here - batch_results = await self.client.request_batch(configs, concurrency=threads) + trackers_by_url.setdefault(config.url, deque()).append(tracker) - # Convert to (url, response[, tracker]) tuples - # Results are returned in the same order as configs - results = [] - for i, br in enumerate(batch_results): + async for br in iter_batch_results(self.client.request_batch_stream(configs, concurrency=threads)): if br.response is not None: response = BlasthttpResponse(br.response, request_url=br.url, method="GET") else: response = None if has_tracker: - results.append((br.url, response, trackers[i])) + queue = trackers_by_url.get(br.url) + tracker = queue.popleft() if queue else None + yield br.url, response, tracker else: - results.append((br.url, response)) - return results + yield br.url, response async def download(self, url, **kwargs): """ diff --git a/bbot/modules/git.py b/bbot/modules/git.py index cb53124209..f9f10b777d 100644 --- a/bbot/modules/git.py +++ b/bbot/modules/git.py @@ -24,7 +24,7 @@ async def handle_event(self, event): self.helpers.urljoin(base_url, ".git/config"), self.helpers.urljoin(f"{base_url}/", ".git/config"), } - for url, response in await self.helpers.request_batch(urls): + async for url, response in self.helpers.request_batch_stream(urls): text = getattr(response, "text", "") if not text: text = "" diff --git a/bbot/modules/http.py b/bbot/modules/http.py index e7e45859b3..f251aac8a9 100644 --- a/bbot/modules/http.py +++ b/bbot/modules/http.py @@ -4,6 +4,7 @@ import blasthttp +from bbot.core.helpers.web.web import iter_batch_results from bbot.modules.base import BaseModule @@ -174,10 +175,75 @@ def _response_to_json(self, url_input, response): return j + async def _process_result(self, result, parent_event): + """Emit URL + HTTP_RESPONSE events for one batch result. Returns True if status was usable.""" + if not result.success: + self.debug(f"blasthttp error for {result.url}: {result.error}") + return False + + response = result.response + status_code = response.status + if status_code == 0: + self.debug(f'No HTTP status code for "{result.url}"') + return False + + url = response.url + + # The "input" field represents the original scan target (host:port), + # not the full URL. Other modules and output consumers use this to + # correlate responses back to the target that produced them. + input_parsed = urlparse(result.url) + url_input = input_parsed.netloc or result.url + j = self._response_to_json(url_input, response) + + # discard 404s from unverified URLs + path = j.get("path", "/") + if parent_event.type == "URL_UNVERIFIED" and status_code in (404,) and path != "/": + self.debug(f'Discarding 404 from "{url}"') + return True + + tags = [f"status-{status_code}"] + url_context = "{module} visited {event.parent.data} and got status code {event.http_status}" + if parent_event.type == "OPEN_TCP_PORT": + url_context += " at {event.data}" + + url_event = self.make_event(url, "URL", parent_event, tags=tags, context=url_context) + if url_event: + response_ip = j.get("host", "") + if response_ip: + url_event._resolved_hosts.add(response_ip) + title = j.get("title", "") + if title: + url_event.http_title = title + location = j.get("location", "") + if location: + url_event.redirect_location = location + if url_event != parent_event: + await self.emit_event(url_event) + content_type = j.get("header", {}).get("content_type", "unspecified").split(";")[0] + content_length = self.helpers.bytes_to_human(j.get("content_length", 0)) + await self.emit_event( + j, + "HTTP_RESPONSE", + url_event, + tags=url_event.tags, + context=f"HTTP_RESPONSE was {content_length} with {content_type} content type", + ) + + if self.store_responses: + response_dir = self.scan.home / "http_responses" + self.helpers.mkdir(response_dir) + filename = f"{j['host']}.{urlparse(url).port or 443}{path.replace('/', '[slash]')}.txt" + response_file = response_dir / filename + response_file.write_text(j.get("raw_header", "") + j.get("body", "")) + return True + async def handle_batch(self, *events): stdin = {} # Track dual-scheme probes from OPEN_TCP_PORT: {(host, port): {"http": url, "https": url}} port_probes = {} + # Reverse index: each paired probe URL → its (host, port) key + paired_probe_urls = {} for event in events: urls, url_hash = self.make_url_metadata(event) @@ -190,6 +256,13 @@ async def handle_batch(self, *events): scheme = "https" if url.startswith("https://") else "http" port_probes[key][scheme] = url + # Only ports with BOTH schemes are subject to suppression — single-scheme + # OPEN_TCP_PORT probes (rare, but possible) stream through normally. + for key, schemes in port_probes.items(): + if "http" in schemes and "https" in schemes: + paired_probe_urls[schemes["http"]] = key + paired_probe_urls[schemes["https"]] = key + if not stdin: return @@ -198,7 +271,6 @@ async def handle_batch(self, *events): timeout = self.scan.blasthttp_timeout retries = self.scan.blasthttp_retries - # Build batch configs configs = [] for url in stdin: config = blasthttp.BatchConfig( @@ -212,110 +284,51 @@ async def handle_batch(self, *events): ) configs.append(config) - # blasthttp batch returns a native coroutine via pyo3-async-runtimes - results = await self.client.request_batch(configs, self.threads) - - # For OPEN_TCP_PORT probes, suppress redundant https when http already succeeded. - # When probing an unknown port, we try both http:// and https://. If http works, - # the port definitely speaks HTTP — the https result may be a proxy artifact - # (intercepting proxies like Burp terminate TLS themselves, making any https:// - # URL "succeed" regardless of whether the target actually speaks TLS). - # If http fails but https succeeds, the port genuinely speaks TLS. - # Explicit URLs (URL_UNVERIFIED/URL) are never suppressed — this only applies - # to speculative OPEN_TCP_PORT probes. - suppressed_urls = set() - if port_probes: - successful_urls = {r.url for r in results if r.success and r.response.status != 0} - for key, schemes in port_probes.items(): - http_url = schemes.get("http") - https_url = schemes.get("https") - if not (http_url and https_url): + # Suppress redundant https probes when http already succeeded for the same + # (host, port). When probing an unknown port, we try both schemes; if http + # works, the port definitely speaks HTTP, and the https result is likely a + # proxy artifact (intercepting proxies like Burp terminate TLS themselves, + # making any https:// URL "succeed" regardless of whether the target really + # speaks TLS). Explicit URL/URL_UNVERIFIED events are never suppressed — + # only speculative OPEN_TCP_PORT probes. + # + # Streaming requires per-pair coordination: emit http immediately, defer + # https until http's outcome is known (or the stream ends). + http_succeeded = {} # key -> bool, set when http result arrives + deferred_https = {} # key -> result, awaiting http verdict + + async def resolve_https(key, result): + if http_succeeded.get(key) and result.success and result.response.status != 0: + self.debug(f"Suppressing https probe {result.url} (http already succeeded for {key})") + return + await self._process_result(result, stdin[result.url]) + + async for result in iter_batch_results(self.client.request_batch_stream(configs, concurrency=self.threads)): + key = paired_probe_urls.get(result.url) + if key is None: + # Non-paired URL — emit immediately + parent_event = stdin.get(result.url) + if parent_event is None: + self.warning(f"Unable to correlate parent event for: {result.url}") continue - if http_url in successful_urls and https_url in successful_urls: - self.debug(f"Suppressing https probe {https_url} (http already succeeded: {http_url})") - suppressed_urls.add(https_url) - - for i in range(len(results)): - result = results[i] - results[i] = None # free response body memory as we go - if not result.success: - self.debug(f"blasthttp error for {result.url}: {result.error}") - continue - - response = result.response - status_code = response.status - if status_code == 0: - self.debug(f'No HTTP status code for "{result.url}"') - continue - - if result.url in suppressed_urls: + await self._process_result(result, parent_event) continue - # Map back to parent event using the input URL - parent_event = stdin.get(result.url, None) - - if parent_event is None: - self.warning(f"Unable to correlate parent event for: {result.url}") - continue - - url = response.url - - # Build JSON dict for HTTP_RESPONSE event - # The "input" field represents the original scan target (host:port), - # not the full URL. Other modules and output consumers use this to - # correlate responses back to the target that produced them. - input_parsed = urlparse(result.url) - url_input = input_parsed.netloc or result.url - j = self._response_to_json(url_input, response) - - # discard 404s from unverified URLs - path = j.get("path", "/") - if parent_event.type == "URL_UNVERIFIED" and status_code in (404,) and path != "/": - self.debug(f'Discarding 404 from "{url}"') - continue - - # main URL - tags = [f"status-{status_code}"] - - url_context = "{module} visited {event.parent.data} and got status code {event.http_status}" - if parent_event.type == "OPEN_TCP_PORT": - url_context += " at {event.data}" - - url_event = self.make_event( - url, - "URL", - parent_event, - tags=tags, - context=url_context, - ) - if url_event: - response_ip = j.get("host", "") - if response_ip: - url_event._resolved_hosts.add(response_ip) - title = j.get("title", "") - if title: - url_event.http_title = title - location = j.get("location", "") - if location: - url_event.redirect_location = location - if url_event != parent_event: - await self.emit_event(url_event) - # HTTP response - content_type = j.get("header", {}).get("content_type", "unspecified").split(";")[0] - content_length = j.get("content_length", 0) - content_length = self.helpers.bytes_to_human(content_length) - await self.emit_event( - j, - "HTTP_RESPONSE", - url_event, - tags=url_event.tags, - context=f"HTTP_RESPONSE was {content_length} with {content_type} content type", - ) - - # Store responses if configured - if self.store_responses: - response_dir = self.scan.home / "http_responses" - self.helpers.mkdir(response_dir) - filename = f"{j['host']}.{urlparse(url).port or 443}{path.replace('/', '[slash]')}.txt" - response_file = response_dir / filename - response_file.write_text(j.get("raw_header", "") + j.get("body", "")) + # Paired OPEN_TCP_PORT probe + is_http = result.url == port_probes[key]["http"] + if is_http: + http_succeeded[key] = result.success and result.response is not None and result.response.status != 0 + await self._process_result(result, stdin[result.url]) + # If https for this key arrived first and was buffered, resolve it now + pending = deferred_https.pop(key, None) + if pending is not None: + await resolve_https(key, pending) + else: # is https + if key in http_succeeded: + await resolve_https(key, result) + else: + deferred_https[key] = result + + # Stream ended — any leftover https had no http result, so emit unconditionally + for key, result in deferred_https.items(): + await self._process_result(result, stdin[result.url]) diff --git a/bbot/modules/iis_shortnames.py b/bbot/modules/iis_shortnames.py index 838ef5f2c8..339cdcb418 100644 --- a/bbot/modules/iis_shortnames.py +++ b/bbot/modules/iis_shortnames.py @@ -142,7 +142,7 @@ async def solve_valid_chars(self, method, target, affirmative_status_code): url = f"{target}{payload}{suffix}" urls_and_kwargs.append((url, kwargs, (c, file_part))) - for url, response, (c, file_part) in await self.helpers.request_batch(urls_and_kwargs): + async for url, response, (c, file_part) in self.helpers.request_batch_stream(urls_and_kwargs): if response is not None: if response.status_code == affirmative_status_code: if file_part == "stem": @@ -183,7 +183,7 @@ async def solve_shortname_recursive( kwargs = {"method": method} urls_and_kwargs.append((url, kwargs, c)) - for url, response, c in await self.helpers.request_batch(urls_and_kwargs): + async for url, response, c in self.helpers.request_batch_stream(urls_and_kwargs): if response is not None: if response.status_code == affirmative_status_code: found_results = True diff --git a/bbot/modules/ntlm.py b/bbot/modules/ntlm.py index b46b938617..4478b1c482 100644 --- a/bbot/modules/ntlm.py +++ b/bbot/modules/ntlm.py @@ -96,10 +96,9 @@ async def handle_event(self, event): urls.add(f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/{endpoint}") num_urls = len(urls) - results = await self.helpers.request_batch( + async for url, response in self.helpers.request_batch_stream( urls, headers=NTLM_test_header, allow_redirects=False, timeout=self.http_timeout - ) - for url, response in results: + ): ntlm_resp = response.headers.get("WWW-Authenticate", "") if not ntlm_resp: continue diff --git a/bbot/modules/pgp.py b/bbot/modules/pgp.py index 655e76e8a9..51c042a514 100644 --- a/bbot/modules/pgp.py +++ b/bbot/modules/pgp.py @@ -37,7 +37,7 @@ async def query(self, query): results = set() urls = self.config.get("search_urls", []) urls = [url.replace("", self.helpers.quote(query)) for url in urls] - for url, response in await self.helpers.request_batch(urls): + async for url, response in self.helpers.request_batch_stream(urls): keyserver = self.helpers.urlparse(url).netloc if response is not None: for email in await self.helpers.re.extract_emails(response.text): diff --git a/bbot/modules/telerik.py b/bbot/modules/telerik.py index 39507c6d7e..e114093cc5 100644 --- a/bbot/modules/telerik.py +++ b/bbot/modules/telerik.py @@ -299,9 +299,8 @@ async def handle_event(self, event): url = self.create_url(base_url, f"{dh}?dp=1") urls[url] = dh - results = await self.helpers.request_batch(list(urls)) fail_count = 0 - for url, response in results: + async for url, response in self.helpers.request_batch_stream(list(urls)): # cancel if we run into timeouts etc. if response is None: fail_count += 1 diff --git a/bbot/modules/templates/bucket.py b/bbot/modules/templates/bucket.py index 50083906c9..1c96eeaf8d 100644 --- a/bbot/modules/templates/bucket.py +++ b/bbot/modules/templates/bucket.py @@ -131,7 +131,9 @@ async def brute_buckets(self, buckets, permutations=False, omit_base=False): for bucket_name in new_buckets: url, kwargs = self.build_bucket_request(bucket_name, base_domain, region) bucket_urls_kwargs.append((url, kwargs, (bucket_name, base_domain, region))) - for url, response, (bucket_name, base_domain, region) in await self.helpers.request_batch(bucket_urls_kwargs): + async for url, response, (bucket_name, base_domain, region) in self.helpers.request_batch_stream( + bucket_urls_kwargs + ): existent_bucket, tags = self._check_bucket_exists(bucket_name, response) if existent_bucket: yield bucket_name, url, tags, num_buckets diff --git a/bbot/modules/web_brute.py b/bbot/modules/web_brute.py index 61d87d2da4..6c772582a7 100644 --- a/bbot/modules/web_brute.py +++ b/bbot/modules/web_brute.py @@ -3,6 +3,7 @@ import blasthttp +from bbot.core.helpers.web.web import iter_batch_results from bbot.modules.base import BaseModule @@ -182,8 +183,9 @@ async def baseline_fuzz(self, url, exts=None, prefix="", suffix=""): canary_results = [] canary_waf_count = 0 - results = await self.blast_client.request_batch(canary_configs, 4, rate_limit=self.rate) - for result in results: + async for result in iter_batch_results( + self.blast_client.request_batch_stream(canary_configs, 4, rate_limit=self.rate) + ): if result.success: canary_results.append(self._batch_response_metrics(result.response)) if await self.helpers.yara.match(self.waf_yara_rules, result.response.body): @@ -313,22 +315,19 @@ async def execute_fuzz( self.debug(f"Fuzzing {len(configs)} URLs for ext [{ext}]") - # Fire all requests via native blasthttp batch (Rust concurrency) - results = await self.blast_client.request_batch(configs, self.concurrency, rate_limit=self.rate) - - # Index results by URL for ordered processing - results_by_url = {} - for result in results: - results_by_url[result.url] = result - - # Process in wordlist order so canary (appended last) is checked last + # Fire all requests via native blasthttp batch (Rust concurrency). + # Stream results in completion order — canary detection and hit + # collection are order-independent (we only check `canary_found and + # hits` after the stream completes), so per-result work overlaps with + # in-flight HTTP I/O. canary_found = False hits = [] - for config in configs: + async for result in iter_batch_results( + self.blast_client.request_batch_stream(configs, self.concurrency, rate_limit=self.rate) + ): if self.scan.stopping: return - result = results_by_url.get(config.url) - if result is None or not result.success: + if not result.success: continue response = result.response @@ -389,9 +388,13 @@ async def execute_fuzz( proxy=proxy, ) ] - canary_batch = await self.blast_client.request_batch(canary_configs, 1, rate_limit=self.rate) - if canary_batch and canary_batch[0].success: - canary_metrics = self._batch_response_metrics(canary_batch[0].response) + canary_result = None + async for r in iter_batch_results( + self.blast_client.request_batch_stream(canary_configs, 1, rate_limit=self.rate) + ): + canary_result = r + if canary_result is not None and canary_result.success: + canary_metrics = self._batch_response_metrics(canary_result.response) if not self._is_baseline_match(canary_metrics, ext_filter): self.verbose( f"Would have reported {len(hits)} hit(s), but mid-scan baseline check failed. " diff --git a/bbot/test/conftest.py b/bbot/test/conftest.py index dec15b4496..6108626911 100644 --- a/bbot/test/conftest.py +++ b/bbot/test/conftest.py @@ -132,10 +132,11 @@ async def patched_request(self, *args, **kwargs): return result return await original_request(self, *args, **kwargs) - original_request_batch = WebHelper.request_batch + original_request_batch_stream = WebHelper.request_batch_stream - async def patched_request_batch(self, urls, threads=10, **kwargs): + async def patched_request_batch_stream(self, urls, threads=10, **kwargs): import blasthttp + from collections import deque # Run the real entry-parsing and config-building logic unmodified entries = [] @@ -154,38 +155,35 @@ async def patched_request_batch(self, urls, threads=10, **kwargs): entries.append((str(entry), kwargs, None)) if not entries: - return [] + return configs = [] - trackers = [] + trackers_by_url = {} for url, req_kwargs, tracker in entries: url, method, blast_kwargs = self._build_blasthttp_kwargs(url, **req_kwargs) config = blasthttp.BatchConfig(url, **blast_kwargs) configs.append(config) - trackers.append(tracker) - - # Route through mock's batch handler instead of Rust client directly - batch_results = await mock.handle_batch(self.client, configs, concurrency=threads) + trackers_by_url.setdefault(config.url, deque()).append(tracker) from bbot.core.helpers.web.blast_response import BlasthttpResponse - results = [] - for i, br in enumerate(batch_results): + async for br in mock.handle_batch_stream(self.client, configs, concurrency=threads): if br.response is not None: response = BlasthttpResponse(br.response, request_url=br.url, method="GET") else: response = None if has_tracker: - results.append((br.url, response, trackers[i])) + queue = trackers_by_url.get(br.url) + tracker = queue.popleft() if queue else None + yield br.url, response, tracker else: - results.append((br.url, response)) - return results + yield br.url, response WebHelper.request = patched_request - WebHelper.request_batch = patched_request_batch + WebHelper.request_batch_stream = patched_request_batch_stream yield mock WebHelper.request = original_request - WebHelper.request_batch = original_request_batch + WebHelper.request_batch_stream = original_request_batch_stream @pytest.fixture diff --git a/bbot/test/mock_blasthttp.py b/bbot/test/mock_blasthttp.py index a4c2819e15..c4b7c735d2 100644 --- a/bbot/test/mock_blasthttp.py +++ b/bbot/test/mock_blasthttp.py @@ -366,38 +366,28 @@ async def handle_engine_request(self, web_helper_self, *args, **kwargs): return {"_request_error": error_msg, "_response": None} return None - async def handle_batch(self, real_client, configs, concurrency, rate_limit=None): + async def handle_batch_stream(self, real_client, configs, concurrency, rate_limit=None): """ - Process a list of BatchConfig objects through the mock. + Process a list of BatchConfig objects through the mock as an async stream. For each config, if the URL should be intercepted, route it through the - mock handlers. Otherwise pass it through to the real Rust client. - The return value mimics blasthttp's request_batch: a list of BatchResult-like - objects with .url, .response, and .error attributes. + mock handlers. Otherwise pass it through to the real Rust client's + ``request_batch_stream``. Yields BatchResult-like objects with .url, + .response, and .error attributes in mock-first, then passthrough-completion + order. """ mock_configs = [] passthrough_configs = [] - passthrough_indices = [] - for i, config in enumerate(configs): + for config in configs: url = config.url if hasattr(config, "url") else str(config) if self.should_intercept(url): - mock_configs.append((i, config)) + mock_configs.append(config) else: passthrough_configs.append(config) - passthrough_indices.append(i) - # Get real results for passthrough (localhost) URLs - passthrough_results = {} - if passthrough_configs: - real_results = await real_client.request_batch(passthrough_configs, concurrency=concurrency) - for idx, result in zip(passthrough_indices, real_results): - passthrough_results[idx] = result - - # Build results in original order - results = [None] * len(configs) - - for idx, config in mock_configs: + # Yield mock results first — they complete synchronously, no point queuing them + for config in mock_configs: url = config.url if hasattr(config, "url") else str(config) method = getattr(config, "method", "GET") or "GET" headers_raw = getattr(config, "headers", None) or [] @@ -407,20 +397,25 @@ async def handle_batch(self, real_client, configs, concurrency, rate_limit=None) try: response = await self._find_and_execute(url, method, headers, body_str) if response is not None: - # response is a BlasthttpResponse — extract the raw response for BatchResult raw = _MockRawResponse( status=response.status_code, url=url, body=response.text, headers=[(k, v) for k, v in response.headers.items()], ) - results[idx] = _MockBatchResult(url=url, response=raw) + yield _MockBatchResult(url=url, response=raw) else: - results[idx] = _MockBatchResult(url=url, error="mock returned None") + yield _MockBatchResult(url=url, error="mock returned None") except Exception as e: - results[idx] = _MockBatchResult(url=url, error=str(e)) + yield _MockBatchResult(url=url, error=str(e)) - for idx, result in passthrough_results.items(): - results[idx] = result - - return results + # Stream passthrough (localhost) URLs through the real Rust client. + # The native iterator yields lists; normalize to individual items so the + # mock's contract is "one BatchResult per yield" regardless of source. + if passthrough_configs: + async for item in real_client.request_batch_stream(passthrough_configs, concurrency=concurrency): + if isinstance(item, list): + for result in item: + yield result + else: + yield item diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index 205db0f542..d469c98afd 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -26,20 +26,22 @@ def server_handler(request): num_urls = 100 - # request_batch + # request_batch_stream urls = [f"{base_url}{i}" for i in range(num_urls)] - responses = await scan.helpers.request_batch(urls) + responses = [] + async for url, response in scan.helpers.request_batch_stream(urls): + responses.append((url, response)) assert len(responses) == 100 assert all(r[1].status_code == 200 and r[1].text.startswith(f"{r[0]}: ") for r in responses) - # request_batch with tracker + # request_batch_stream with tracker urls_and_kwargs = [(urls[i], {"headers": {f"h{i}": f"v{i}"}}, i) for i in range(num_urls)] - results = await scan.helpers.request_batch(urls_and_kwargs) - assert len(results) == 100 - for result in results: - url, response, custom_tracker = result + seen_trackers = set() + async for url, response, custom_tracker in scan.helpers.request_batch_stream(urls_and_kwargs): assert response.status_code == 200 assert response.text.startswith(f"{url}: ") + seen_trackers.add(custom_tracker) + assert seen_trackers == set(range(num_urls)) # request with raise_error=True with pytest.raises(WebError): diff --git a/uv.lock b/uv.lock index 9778d55ddd..13020af80f 100644 --- a/uv.lock +++ b/uv.lock @@ -268,7 +268,7 @@ requires-dist = [ { name = "asndb", specifier = ">=1.0.4" }, { name = "beautifulsoup4", specifier = ">=4.12.2,<5" }, { name = "blastdns", specifier = ">=1.9.0,<2" }, - { name = "blasthttp", specifier = ">=0.2.0" }, + { name = "blasthttp", specifier = ">=0.3.2" }, { name = "cachetools", specifier = ">=5.3.2,<8.0.0" }, { name = "cloudcheck", specifier = ">=9.2.0,<10" }, { name = "deepdiff", specifier = ">=8.0.0,<10" }, @@ -315,7 +315,7 @@ dev = [ { name = "pytest-httpx", specifier = ">=0.35" }, { name = "pytest-rerunfailures", specifier = ">=14,<17" }, { name = "pytest-timeout", specifier = ">=2.3.1,<3" }, - { name = "ruff", specifier = "==0.15.10" }, + { name = "ruff", specifier = "==0.15.12" }, { name = "urllib3", specifier = ">=2.0.2,<3" }, { name = "uvicorn", specifier = ">=0.32,<0.40" }, { name = "werkzeug", specifier = ">=2.3.4,<4.0.0" }, @@ -450,60 +450,60 @@ wheels = [ [[package]] name = "blasthttp" -version = "0.2.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/41/74af6a882f37b58883f6f48f7e3d0227f5929f080df63cdf1fd82136d924/blasthttp-0.2.0.tar.gz", hash = "sha256:94b396c79e9a2391ea9c07270d88d6270fa049affaa31b923819d7cb5a40e602", size = 58106, upload-time = "2026-04-03T04:33:27.833Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/a4/145d8626afc086225e57ebe754c6f3e44343608e19154ed9f822640329c2/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5913eadf36e84034c60b6a86b706992bad7014fb900569809105109d9d25348f", size = 4329675, upload-time = "2026-04-03T04:32:19.759Z" }, - { url = "https://files.pythonhosted.org/packages/3f/42/ae8d820486759d3356c480c3073cb43682af7bc057e8bd9ca7caac376456/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_armv7l.whl", hash = "sha256:b69664e2c1f3c7606cc301ee918f0cd8492532394d4e80ee7e501c78c4a29bc0", size = 3659544, upload-time = "2026-04-03T04:32:26.434Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5c/7590c50ef72da9dc170bbcf2e29ab15229cd55a2615653efa33bc00a7b3d/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:c229dc156c4da74592ccc95dc20de66d1ad7b4215137a09a4a54a1fefe12dfec", size = 4220396, upload-time = "2026-04-03T04:32:46.236Z" }, - { url = "https://files.pythonhosted.org/packages/b2/20/dd550b3f39610494e0c23618d79c1b4cb19a918ddd619c840442d5660435/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:ca930a387dd6e9c38b2bf5d1b5afd5228007b53aec470b9e9d3ad199976b45d9", size = 4255998, upload-time = "2026-04-03T04:32:33.043Z" }, - { url = "https://files.pythonhosted.org/packages/13/26/30defc894e1eba93284db99cd5cc2850c0bfaf2c0bdf2cb1864cfb2d305a/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:ed103596860675bd80f62df2329db420e5902e19c6841a7ae48d9e9053f95ba3", size = 3863391, upload-time = "2026-04-03T04:32:39.918Z" }, - { url = "https://files.pythonhosted.org/packages/49/8c/626b70789b1974db181fb9963d592ada74e1ac9caa7df1b7497f33a213e6/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:511b13ac0d36875e3093202c7357c4e0ecf88cae4a222638f9d81399ade008b7", size = 4021982, upload-time = "2026-04-03T04:32:53.562Z" }, - { url = "https://files.pythonhosted.org/packages/d2/01/c75e926f36c646ba9991eef7b47d3e34679267948b5c2ef5ffdee0c1b359/blasthttp-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93e9a2d5bf92aaa9310f3c2a69f479da508fad9ac0cb4ae9f18ce943bd5bb9a6", size = 4620989, upload-time = "2026-04-03T04:33:00.498Z" }, - { url = "https://files.pythonhosted.org/packages/b5/79/59bfe5a7395543badad6e01413f0a3eac46dec9fc48b5ff5c6a59d76b31a/blasthttp-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:fadd1864fc7da11b56fc455c5609229c8075b72d468cd241bad495cf361f79bf", size = 3962193, upload-time = "2026-04-03T04:33:08.002Z" }, - { url = "https://files.pythonhosted.org/packages/f3/82/2dd1f89f1fbc8528ccb60530263a5a74a3557783ed29bdaba0833268e60f/blasthttp-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e58be764ebfc3b4e49fabd6df6d9d1c050940b585d4bd7655040f5c8713a7c38", size = 4367489, upload-time = "2026-04-03T04:33:14.263Z" }, - { url = "https://files.pythonhosted.org/packages/c8/0b/b6c902c368d3cbce14bad9077a48eac75f87859555f42a34b8ebbf02ed1c/blasthttp-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3689e259334384afd9b6e51b86b40f029f4cbc4d15328ee2df7a3d075b68ee8d", size = 4341965, upload-time = "2026-04-03T04:33:20.566Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c0/90450af2a5dcc623db924a924c0a70adf3e95e2c0f6f3198a1a09ee7f590/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d5616e6c282b552d06408d6819f0d1a978c26cf2a325190d925f29472463f6b5", size = 4326488, upload-time = "2026-04-03T04:32:21.184Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c1/04b224e27fd0c50e77d5431a0bb3feb68b5e58738ae3b6d3e35b2a0820a5/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_armv7l.whl", hash = "sha256:ec670c9e488212b9c19c1ff5d377a18e67a12684dc472378c72646f562bdfea1", size = 3655940, upload-time = "2026-04-03T04:32:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/9a/39/912f3e2868a20046b72282aa3297a555ba84776e1df56972e89b0029a0ba/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:c9d715be62edf5ca5a2b01c51e11a2626d12967b44f99d44aed66dedb1328709", size = 4218146, upload-time = "2026-04-03T04:32:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/59/40/c878f73c60ab585862f51b6ac849041710a93c607abb26a42b868d0271da/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:a61bdaa41d8c2113022b20ed2e766327bdac0ef244a4b7688a46107d31c48f08", size = 4253976, upload-time = "2026-04-03T04:32:34.627Z" }, - { url = "https://files.pythonhosted.org/packages/f0/5c/0ae9a6f55366a1fc20198dd5eb7acbb503f5d4cca66d9a6f6195d7cbf160/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:8ea1c69d08fc95fb6f5ed90cd10175d4218cf96d4d326bfdc3c83de0f60feb03", size = 3859892, upload-time = "2026-04-03T04:32:41.132Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/6a60b8d386b467c0e8e33152738cc3c33abdee33855fc9af5126023a410d/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:60e97e1a1581a901af7f4442ced0ad17aa6574cfb7d1ac234dd057248df7a14d", size = 4019079, upload-time = "2026-04-03T04:32:55.077Z" }, - { url = "https://files.pythonhosted.org/packages/46/26/e622ecc31f6721d68b6a52426e92968c3fe39e00eecca463fce78921169e/blasthttp-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f3facae2a0e3be7eca3c7c0b4a112d5ef12fed8c4fe40c38c0b6e433b5ec884", size = 4616754, upload-time = "2026-04-03T04:33:01.864Z" }, - { url = "https://files.pythonhosted.org/packages/c2/38/084209f1f8e5575607ebe6fd7d31cdd95439bec53f525795a67545738c30/blasthttp-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1b5f046b7571fe311af3b1f2cfac7b907beb072cf0f66b47a906f1a6f7d58cba", size = 3961015, upload-time = "2026-04-03T04:33:09.411Z" }, - { url = "https://files.pythonhosted.org/packages/dc/57/d69490381817665aaecd2b98fa0d616e51b9faf27bde9990e8dd9ee952c4/blasthttp-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9934310b4e7e9b856b198dc34ef5b856d179cb37938a2f61338c2871bfbefd67", size = 4363684, upload-time = "2026-04-03T04:33:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/39/20/dd70aa7e670e4973508a2fec7eabde0b1073a77763f906b488a5abca124f/blasthttp-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:565d364c78c949772dce9b9f14aa16a00492ef29a55758b5129f39015959f5dc", size = 4339653, upload-time = "2026-04-03T04:33:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/bfcee47aa6726d3f305090e8ef2b1c3756eda97eedf792df48f4c5a4f9db/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0313952af506b0810c429bd2e83c7174079646811ee31144a5d2f021863be4ca", size = 4325829, upload-time = "2026-04-03T04:32:22.424Z" }, - { url = "https://files.pythonhosted.org/packages/53/69/412d1b74c06d1ecf52d4a31591b2b9ac02fb1c7bc9ba12ec7a875e815c35/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:ce01d0416fd4a2e0e1c384683d939bc9f24c81075b0e696a5a4b971cbb89ea61", size = 3652836, upload-time = "2026-04-03T04:32:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/83/31/c427e70a70d5f7e26135d3cbc5d238c41ae25ac230a45891096bf49b7569/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:73a63f3f10586893f9fc553d6c1909a27a7262b46758721bc22a7c7ab2048b41", size = 4215009, upload-time = "2026-04-03T04:32:49.262Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a9/4aa4d31ed5bfca64a954ec8b30f239330ecbaa2e7bf8904e072775c02958/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:1c4d80c608ee1d804cd0a31407d20ad6eb6c8b95b364852d811bb3b47f28fb96", size = 4255433, upload-time = "2026-04-03T04:32:35.974Z" }, - { url = "https://files.pythonhosted.org/packages/bd/20/c87ba23fbaeca4b481b57d90f283093291acde26e30fcd532ebef0f9c676/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:83c835a6d22299f14ed829648b4297bdcfa348604d1e9b59af502eb3a425aa3e", size = 3859175, upload-time = "2026-04-03T04:32:42.305Z" }, - { url = "https://files.pythonhosted.org/packages/7e/2d/8150eaeeeb031ddb878dbabfc951bea80c2e5926820a897767c52fa22a8b/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3bcb5bfeb2cd616dc9e5124377e19bbff4578062c697bc25bbecb89d5bf38c01", size = 4020354, upload-time = "2026-04-03T04:32:56.362Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ca/2e3c76bbcda25cf5b736d45d2291c86417eaa4a49cdfecede7d9395a5989/blasthttp-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1148b5995b587d7cceeaf3216cc37f7c80d5cf47c48a5fc846b5a82bf3a93714", size = 4616044, upload-time = "2026-04-03T04:33:03.42Z" }, - { url = "https://files.pythonhosted.org/packages/7f/9f/47356ba8441beff2a6c728f6a6a8c88e75bc6f7b10b232f4d38fcedbfb81/blasthttp-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:947011d25059e48de9511bb8ff25c738059b81a1305191d9ea65ea03c25c43f6", size = 3958179, upload-time = "2026-04-03T04:33:10.647Z" }, - { url = "https://files.pythonhosted.org/packages/c4/86/0f6dff3c0067e622980253e8f75924437f328a1179cfd3869b1eed472dca/blasthttp-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0a9cd1f6dcf0070e2ccb8275196077de2abd04856718859579d306cbc7a6780e", size = 4360650, upload-time = "2026-04-03T04:33:16.99Z" }, - { url = "https://files.pythonhosted.org/packages/3c/ca/c3456609fa332dde2e87e95549d99d958a511f0295c104ab95b1b490b1e6/blasthttp-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:644eb9861303113a3ff3780e943c72f440835f0f0d3f73f1280ded8e10a4e5f0", size = 4340404, upload-time = "2026-04-03T04:33:23.294Z" }, - { url = "https://files.pythonhosted.org/packages/63/32/04c3fdb7b0ce2f75af24bc908f8eab4d3f1005929554d0994da25485777e/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9f11a353be8da6d7474a4299538b207a14828923bcd7560718453ffc426633ec", size = 4326834, upload-time = "2026-04-03T04:32:23.821Z" }, - { url = "https://files.pythonhosted.org/packages/51/a9/553833776745cb17693cac4dd03b3b38c13b1ff5582dd260ad857e7006da/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:c818ac0b0f4515f7dc08000c09ef51a51edeb46c363650149854c1b449df5e84", size = 3654702, upload-time = "2026-04-03T04:32:30.663Z" }, - { url = "https://files.pythonhosted.org/packages/ec/37/d25564e28a0d7e76aa61d0ba0dba9f2c644536a054f01414df02e5844a5b/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:761668e903581d3fff7ce23e4cfb7d5f81331c3ae90ad9af224c053b28812c16", size = 4212839, upload-time = "2026-04-03T04:32:50.56Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9c/fb281fe2af54bb30851c55973fc96f62d6f910b8a5510e2acb1d6c27011a/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:cb98ca7478a7e09509c3223514c2c3f06aae54d09afe77fd29d42933731f9407", size = 4253492, upload-time = "2026-04-03T04:32:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f4/a76d12aa5bf9df6020dddd9ae6b79081356d8a338f39f44fcf07ebbf06c5/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:8232cd36ff05be76565d61e6b141b2bddc79e4a359d137a6810767cb5e6f6424", size = 3859735, upload-time = "2026-04-03T04:32:43.583Z" }, - { url = "https://files.pythonhosted.org/packages/1f/5e/5c38aec9625b2dca2290a1f4a0c7e4925dd0d82dbbc00bac7273e415cc32/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:400c95f58b1fbe21ae1209780f27014c47e4b302cbb3c2fcbf18a48c7c59d090", size = 4019837, upload-time = "2026-04-03T04:32:57.578Z" }, - { url = "https://files.pythonhosted.org/packages/48/e5/68a003b4ce28bf9309501bbfe2f1c96341542edeb648f0a3287b223196a4/blasthttp-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:622e4c953b8a70c70d7b0ca6f1d54de1bf0f2ef4eafab9245f6fca9102f4f03b", size = 4616450, upload-time = "2026-04-03T04:33:04.766Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f9/4f73dea2371094ca336aa5a90c4c5171382ba98553552f3e639d4e924316/blasthttp-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d1748052f2f378a56cff91f0c6f567a24e2d24640ab45076013434c870b6abcf", size = 3959757, upload-time = "2026-04-03T04:33:11.859Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f8/921e049ed1a72e7d1d4444b5c457ac020ca88f5b0b39b8a94273b91446db/blasthttp-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2dd4fc6c5da320f2f3a46dba9f05766cdf627a5de8eb6edc249eb10ac6204380", size = 4358090, upload-time = "2026-04-03T04:33:18.156Z" }, - { url = "https://files.pythonhosted.org/packages/70/49/e6d214ea29d874fc62004d5514e562281a3c5b745f06d8cc3f9dd1ae5407/blasthttp-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5e366ed494a4ccff35248d26a88b143d0e784c6741058f360859691e04cd5d75", size = 4340178, upload-time = "2026-04-03T04:33:25.093Z" }, - { url = "https://files.pythonhosted.org/packages/11/ac/db0abd5969e7146fda4fcd6957c479d3dd793185b2f0405a122af275b616/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:5567a9e1be296b165cfd1765f5ae42cfb5cc0d76b2b4eb249cd45ef120a84991", size = 4325710, upload-time = "2026-04-03T04:32:25.211Z" }, - { url = "https://files.pythonhosted.org/packages/a8/83/6c3608324775ab6c0cbaaed926b11e47bfab75bf77cc433371d152919088/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:0c5bcb67b9a9446530d8b7c796dde2cbe7a10c0ef88828bd29520de666587fda", size = 3654962, upload-time = "2026-04-03T04:32:31.824Z" }, - { url = "https://files.pythonhosted.org/packages/98/bf/1fe69d59196ff02f61100b1bb20ad5dc0d41f3159877293fa9b920f65d27/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:4c2a966e6ec4bf57dc080c27c65110592cc2e1eb499073c8c7aa70b0351b8f8d", size = 4213633, upload-time = "2026-04-03T04:32:52.007Z" }, - { url = "https://files.pythonhosted.org/packages/85/0f/862810e581fd8cad5140c549fc0df706f11f3fb7074a1819b52867871c6f/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b2fcc5a36846e1ab14c2c874bb98b3673275ba1f91128d1d134168e7f0469662", size = 4253218, upload-time = "2026-04-03T04:32:38.697Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/f92fb9b304080453e15fa6cf84c716ac64d21d717e4f850183c56eab7633/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:fa180f2b1ec7db5755b1e267a413f73f3d39ff31f20be1ec033a367ceadd2b65", size = 3858420, upload-time = "2026-04-03T04:32:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/418661b162c8a356adfff5266d0937d43ce8a5ae7424f6feddcb58c36020/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4f32a0df53bc0ec3a7f1b18e7b204b093eb60034ae34b0aad62587b5f79b7767", size = 4020170, upload-time = "2026-04-03T04:32:58.948Z" }, - { url = "https://files.pythonhosted.org/packages/79/7a/56d23094548aca867cbb03b2889c3bce309f59334f1c3dadba1e78204a5a/blasthttp-0.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d958dc62c2006ecd05660bbfb5f7e549c53d8aa35ee0e3fca51c4d7cdb58d485", size = 4616169, upload-time = "2026-04-03T04:33:06.046Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9c/232492eac2e5f2a6ef232317d9fa0373b7bfe98e2fb72fcf356f8893b4fc/blasthttp-0.2.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ba290ff36e3cf5905f19f008185350ab3c376b73a3064fd776a8b08e56e3dd7d", size = 3958090, upload-time = "2026-04-03T04:33:13.07Z" }, - { url = "https://files.pythonhosted.org/packages/72/3b/584c05f053760f9a68ce743ca34de313b72323fd6a99362af17a466acfa2/blasthttp-0.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:308e0feb2360b90ceb90c8cf70e29c0b07ec5f3946bf54dfc5124b69b01e99e4", size = 4356563, upload-time = "2026-04-03T04:33:19.333Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8e/f3c0d5563523dcd16705977a75cb460b77f1eac236eee04be33927930160/blasthttp-0.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db61cc79fd0843ff2d9b016d4578901955a7758fcddabc5ac843a60dfbc80569", size = 4340145, upload-time = "2026-04-03T04:33:26.484Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/90/86/0dd004f90fa242271b18e4767714969b92bd8bbedeb0eaf17924f9638aea/blasthttp-0.4.0.tar.gz", hash = "sha256:9c2280e8fe6aea609e6bb5c3801f9933798bdd397b0f9e6438e4dd4dc2a38599", size = 111609, upload-time = "2026-05-01T16:11:37.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f2/2c93f63eb949bed093b51841334a7f0e55b4f33c391abece72a9fd486d18/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b4685f7b9958900d277fbef4779441b64d9041a80eede1cb75ece786030837ef", size = 4566226, upload-time = "2026-05-01T16:10:17.651Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/a47fad5edd6ac09f76c9ec08d9816dc67ea1fa74b92a8a6af6f3b39c533b/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_armv7l.whl", hash = "sha256:7e941013fc1b4a847e8ea5af28bfbe846c15c9062b54feef9e441afc9f5116c8", size = 3887426, upload-time = "2026-05-01T16:10:25.704Z" }, + { url = "https://files.pythonhosted.org/packages/60/0e/4160d21a3bcfc0889ef3414b239dc24fd92320feac6c25ae545fe8867bba/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:5ba87482dfed7c242c4707f85a4888d3799e443a55d821d426eb97e7429bded3", size = 4467981, upload-time = "2026-05-01T16:10:49.799Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8f/9cc581d5407c4340239b097238e9f8a55c9bcbd2d0336419b10e14e09edf/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:67c3b8f470ab0a86c7060811e4e21433a81bb507ba9c9a21cc8abc3a7d651239", size = 4472838, upload-time = "2026-05-01T16:10:33.591Z" }, + { url = "https://files.pythonhosted.org/packages/44/12/1d7b61e76c684a31b081b086dc3558a0d7cd39050488ebf722d13d9cf15e/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:d2a59cde9750782c56c6220c8d5c1af741f052e070f6015dfe81f0cc1ab11c39", size = 4096366, upload-time = "2026-05-01T16:10:41.958Z" }, + { url = "https://files.pythonhosted.org/packages/e3/90/f411e41e55771f29dfec1a33b157f79bdce299bbe3f4865ac9d1b7bec7ae/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:fec615295b33d76895bf047f2109cc41d1b5775bc5db40bc5c73b7b98bebfcdf", size = 4230482, upload-time = "2026-05-01T16:10:57.34Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e2/58c0d6975195f3ec9fc96313850457a8fc0feb7d5e90049f250bca3009ce/blasthttp-0.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f05bd49b19294d2ee74d6e3bf038b22363db9abe0df4d47d174d9100c569efbd", size = 4857209, upload-time = "2026-05-01T16:11:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/fe/35/560c6ef2a7616d1cb0d6caaf7296491d72fa1eb07f49e654dca346d11c4b/blasthttp-0.4.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:2ab3dd4fb4e02b2b4c5d85e31598d49ad3d44292a94ad6c4e76f8e5ab01e9420", size = 4191448, upload-time = "2026-05-01T16:11:13.509Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2d/14741faa212e0541254790b2735bc269beed27e4d4ca51324e697c912bb1/blasthttp-0.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8df2a897e01a27c2caed3f7604e2dabb69a3e1c54834283c2f0cfbba48e4a6df", size = 4607221, upload-time = "2026-05-01T16:11:21.33Z" }, + { url = "https://files.pythonhosted.org/packages/30/3f/631cce54d4ce277e28ff7ed7e74703ea41f8d4ba872c72bd7f1376aa94ff/blasthttp-0.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6fea4489e0e590140d3890983e9197d6348b40b3e225a339d6af2b6a74a8ae7b", size = 4564472, upload-time = "2026-05-01T16:11:29.379Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/7cae1cc77a1eeefed8c2727b12e72935f66ec19ef5518c2c01eca25c76ca/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:693a1379940102187b53d96152c150b3771c9eb22d89de1f42cd0d2e21420153", size = 4563775, upload-time = "2026-05-01T16:10:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/a9/01/a462eaab0771413ad448aef0ce28259a054bac1da8dd59a9a25f1e7a2e86/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_armv7l.whl", hash = "sha256:aab65d1d8e4e837a180f8a6db0d9d16d662319dca054fa694daa37080cebe22b", size = 3885817, upload-time = "2026-05-01T16:10:27.457Z" }, + { url = "https://files.pythonhosted.org/packages/4d/7b/9569080a6f46d2465f25e1e7fb2ce7db1b096b7a66d72ecf95da177a303c/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:acb396238e7704dc5bbf104be8b60f8bd0ecda9320051474eaefb8974168e7b6", size = 4465003, upload-time = "2026-05-01T16:10:51.55Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/84a897080c47bc401c41f661a4c05879d85bd97d12c0799f553144385c19/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:c0199cea79e8bdf49ea9c70d06771ca60618382940aac0d9a22ef3f101517c0a", size = 4472062, upload-time = "2026-05-01T16:10:35.357Z" }, + { url = "https://files.pythonhosted.org/packages/da/e5/6ae9f82d7f317d3a406b0d47ca9255c724c473caa365cd30b63ac735456f/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:cb081c0b53761e6ca30742d706c039f1186c38f2804230403db4856dc83d4079", size = 4091245, upload-time = "2026-05-01T16:10:43.67Z" }, + { url = "https://files.pythonhosted.org/packages/e5/20/91c8720d7be6722f72d161b09ecc077a8be66c9a798fdd928013cb1a3bfa/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6c91056098933aeb0380d43716379d431d06c1945ce0a567e7cf7c3f77b4784f", size = 4227936, upload-time = "2026-05-01T16:10:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e7/92a5f0ee00d85198a30566317535ff89855cdc0a97c77f857c95bb0b9fdc/blasthttp-0.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:99655b1ab1f580b451067d7613e28f45b9e333fd62fdeecb2790cc4a4c2b3c45", size = 4854465, upload-time = "2026-05-01T16:11:07.143Z" }, + { url = "https://files.pythonhosted.org/packages/03/a5/a26599dfcf7484b2f60df00aede182b5a7abe1296dfd1e36c196229d700a/blasthttp-0.4.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a20e7656a7cfb3586325c5857dd542e8d33626a7b59919a0410018e0ea8963c9", size = 4187569, upload-time = "2026-05-01T16:11:15.031Z" }, + { url = "https://files.pythonhosted.org/packages/73/9d/a790bba458ac9ffe13581dbd5e1720ab611b1d97a412951db54c82299935/blasthttp-0.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ae8156905d1389fe86151b989694c6a04f31810b989de6fb1369407b3ca459de", size = 4602503, upload-time = "2026-05-01T16:11:23.022Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/4e55bc49eb56e9bfd2c89d2dd6dca954c217b79e25b9b701d90d5b3162a5/blasthttp-0.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:49f6a1fd2dc3fb04884af2c7a49724a86b8110d3ffcd2af8617f57e4cddbcf77", size = 4562715, upload-time = "2026-05-01T16:11:31.341Z" }, + { url = "https://files.pythonhosted.org/packages/54/54/d40f61c10a7115d9daa553879a0642124f24b716faf7dff2674301a42eed/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:702262236f06ec08517bf18560bf2f8f26e5fbdac4f96ae36f57c8a104c4c15b", size = 4561829, upload-time = "2026-05-01T16:10:20.729Z" }, + { url = "https://files.pythonhosted.org/packages/53/6b/6f349ed6d27f42fa8f4a9842a713d6e56b353c2284c00191f7c745eada0c/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:a4b027c113ef6a6c4a454679a0194aadfffb124392982ccf1ef7eb5c33334f83", size = 3878225, upload-time = "2026-05-01T16:10:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/40/47/1cf7005e655bb3513fb849f0f7475139ea2d9e9f4bd342561ecc6d078ab7/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:4e1470ca76bc46909e335b4ee8b4d4b72611f765672388d2c3c89df5d7b5a591", size = 4458508, upload-time = "2026-05-01T16:10:52.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/b6/6d1d6abd8e28911513fdcd01bc8edbb03fec938b52b50c222fe19115fb77/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:18d4476e3778aa28350386e76f7227b571fa7d058f69227d22c99081a000de14", size = 4469432, upload-time = "2026-05-01T16:10:37.016Z" }, + { url = "https://files.pythonhosted.org/packages/9f/44/67fbdf7a040372bfcfd8a8a737d792d442602e2d0d7416f2a7938994109c/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:0f6fe7287e3f5ecd51959b67484f17160ac71d744d60d274125e4d012a7ba748", size = 4090502, upload-time = "2026-05-01T16:10:44.969Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f1/95ef78dbfad4626c70e26b0d3fd19352e0d634c4edd3d06a13edb77ee651/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6a052865813d60dca690fb9561f8d60c96d93ecfb8001434dc7efef1454c0b16", size = 4225151, upload-time = "2026-05-01T16:11:00.58Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4d/2af0d419952c152f4505d74fbc90f01efdda21618e965d7f60f3cd510f4d/blasthttp-0.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:34d872d366f4e7c2db88245a06884c155f0ff4746d5b78ebd46910d3044c4443", size = 4851561, upload-time = "2026-05-01T16:11:08.918Z" }, + { url = "https://files.pythonhosted.org/packages/71/49/b64aac3e321016629d2fef12806be50791cfd22d2ddda2bd9a2467cbed40/blasthttp-0.4.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f852ee3b73d75cdb19fc5d8cc6c9982af43e0b59fae168339530bb47da0c29b8", size = 4179050, upload-time = "2026-05-01T16:11:16.692Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/c283434d5a114d14edae9766f03f8ba5361f26a0f5a0d33cf6a1dd4a1c4a/blasthttp-0.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a2bf53b2f91310750161ffe7f97567cf634c3022a7200fc02be1f15b3ed7d606", size = 4598661, upload-time = "2026-05-01T16:11:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7b/fefe45fd2d989fc72d15a60aa77f9d20b95f9f482cb5af3af84161ac5665/blasthttp-0.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b049ce40b9959cfd31cec481482147791a660eeed7aeef262e34d3ca843a499b", size = 4559800, upload-time = "2026-05-01T16:11:32.783Z" }, + { url = "https://files.pythonhosted.org/packages/33/01/86f0af776d4208c8a787adc465d7851bb9ea0e05f117e1f85f2abe31d9f6/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:30bdbe55fd1883d4f8973adaba2d29e476d9f895b50f298fa8d84bbff6f315ed", size = 4561981, upload-time = "2026-05-01T16:10:22.554Z" }, + { url = "https://files.pythonhosted.org/packages/33/0c/9c587cb9c8b507230c3f72a405c9880138577a464b42126cfa2df615090f/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:cddca738614895ac67ca1e858787296ad515760c145edcd74240950be0dd4b17", size = 3881449, upload-time = "2026-05-01T16:10:30.279Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/2bdc6052c51470a569ca499a52727003fad20445bc64763de3fd0408543d/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:17f0574eafcf543606de71a21ca62f1a524a4123ee5c5c767f99448c8dd6084a", size = 4463029, upload-time = "2026-05-01T16:10:54.333Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/de5c6070408b6ca9321e65107d22d28033d9425059f439318c1d23ee083d/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:0e192488053fbc52f49a610d94f379806a0897f7f7b2d60857a3fbfb4c2a3403", size = 4470584, upload-time = "2026-05-01T16:10:38.609Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/c8abd6ed3a5c2428fd41758b87082030c5c98a87aae4f8f18312169e6f2d/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:b48b1b12a56848c98a5aee78ba58c549962d022b404a712b95df95f0e43e03c7", size = 4090171, upload-time = "2026-05-01T16:10:46.715Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a1/aedceff8653eaaa114111971a033c1be9cc44997272be74685021b3b7b60/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b80d782724df6653249c5be61fce9b77a2fd9fde295e72bb8f7daf7077c9341f", size = 4225924, upload-time = "2026-05-01T16:11:02.687Z" }, + { url = "https://files.pythonhosted.org/packages/31/b4/2232a321fc048d0ac4b10a7daf344dafeb39942151b067bf4f6ff628ac5f/blasthttp-0.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96a92b74058475023ada7e02e5cca2c1b69e368582b644a8915f4132fcf310cc", size = 4851739, upload-time = "2026-05-01T16:11:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/84/f2/a516049c47bbe7ca5d2ffa9dc24f467eef7ce385c8833c5f2ea38468db45/blasthttp-0.4.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0bc5d99529aa80884aa610b9f465d06aef4e75131513f856917c38bddbd19c74", size = 4180110, upload-time = "2026-05-01T16:11:18.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e5/2b4d6cd9cafd64c97f154be6475552362f35e7c09f2c8fac781ffd1f5971/blasthttp-0.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cb09f3bfe11fc58a67c9d6c19f67249579599683c2e77219b3f3c694084010e5", size = 4597857, upload-time = "2026-05-01T16:11:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/85/cc2dfb5412a29231ee8bfd177b4a817909e8f95414c394f51cd4882a849f/blasthttp-0.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:396a66dee3f52058da61ea8254f576ca70f3d4eb54d5b5a783455d8b7dbbbd23", size = 4559871, upload-time = "2026-05-01T16:11:34.221Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/1fb15d9eef665a29c2906a60b5ece728e078eefffa6eb8e793d164bbdc84/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:81a7d53b6ca4e5622bb156edbdc8c5f763992076d68503b2c18089532f2fab5a", size = 4562881, upload-time = "2026-05-01T16:10:24.098Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e1/16a4af8cf1f184103e21b2bd0a3c14165bafcf704f266656793211e53c0e/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:8feed0dee3a750e5d49ee4e38684f8a94e727bfc95feb554fd29021ef77a8522", size = 3879561, upload-time = "2026-05-01T16:10:32.042Z" }, + { url = "https://files.pythonhosted.org/packages/87/ab/ef42ef4afa33ee974c4eae3492bf59da60558072116e114e11eab39e8aa9/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:b45f7aac0885ce1a8a9125bfbaf0d4b7213d5297d98bb68b319efbf02b7103dd", size = 4461286, upload-time = "2026-05-01T16:10:55.779Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a9/d6a6032c4f9022d6245c92671c96bf32c602d68902d8463017e03c576498/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:576206e78de4dcb7c299e63a474180f577c1078c572cd6b7883dd352a9042b45", size = 4470099, upload-time = "2026-05-01T16:10:40.128Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/336b261e136c583e75de8462ae096c46b2cb2506052ffefd61bfd73d8c95/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:1f7ba239eb7ba2d91470d8487e0800c7f18b5115762b74645f3f0708f4b7a417", size = 4091509, upload-time = "2026-05-01T16:10:48.207Z" }, + { url = "https://files.pythonhosted.org/packages/57/c7/c50ea8a20ddde64e1268e94ae9db83278933f51ad21c2654ee2527c33ac6/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e1e5374d5fe3e1d3c28d514228b8b0129e0b6d20cc78efc87d65b27662dae3d9", size = 4224394, upload-time = "2026-05-01T16:11:04.077Z" }, + { url = "https://files.pythonhosted.org/packages/de/b5/fb9c16d6da024855da2390cfebb770586eac14bd5f6dddc3dde610161965/blasthttp-0.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2500b1974bce2df2b7acbacdea5012d65abe2de93897fe7576b2cb7f9e7e99cb", size = 4852825, upload-time = "2026-05-01T16:11:11.916Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/e096bbca96b7d2b93b21ff87d757347efdfd39edd7189edde9633e179d93/blasthttp-0.4.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:09af4c79af71b991afc32b9d73694b2715c0f42d04690b8878049c91250b61b3", size = 4179218, upload-time = "2026-05-01T16:11:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/899813711c2d7013230ed0d87f518d9f4e4705eb1543e4cc36be973d807b/blasthttp-0.4.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8f02bc943dd18345d58a78f1ec7e86c5cabbb94a150e8fbc1389822fbd313df6", size = 4597955, upload-time = "2026-05-01T16:11:27.652Z" }, + { url = "https://files.pythonhosted.org/packages/af/cb/7ac9f9d247aed87c537677cf22d7d5bb733e9ab28f0c023703ede72fc603/blasthttp-0.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4a893b6ef9d9e60e8163e465e241b229c8f2c5f9bcc85de274ccc9cbc6d2782d", size = 4558895, upload-time = "2026-05-01T16:11:35.928Z" }, ] [[package]] @@ -2808,27 +2808,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]]