diff --git a/.changeset/silly-seals-care.md b/.changeset/silly-seals-care.md new file mode 100644 index 0000000000..e2316cf1ab --- /dev/null +++ b/.changeset/silly-seals-care.md @@ -0,0 +1,5 @@ +--- +"gradio": patch +--- + +fix:Patch `async_save_url_to_cache` for Lite diff --git a/.config/playwright.config.js b/.config/playwright.config.js index 0d5f432fd7..04865fba4d 100644 --- a/.config/playwright.config.js +++ b/.config/playwright.config.js @@ -39,7 +39,8 @@ const lite = defineConfig(base, { "**/file_component_events.spec.ts", "**/chatbot_multimodal.spec.ts", "**/kitchen_sink.spec.ts", - "**/gallery_component_events.spec.ts" + "**/gallery_component_events.spec.ts", + "**/image_remote_url.spec.ts" // To detect the bugs on Lite fixed in https://github.com/gradio-app/gradio/pull/8011 and https://github.com/gradio-app/gradio/pull/8026 ], workers: 1, retries: 3 diff --git a/demo/image_remote_url/run.ipynb b/demo/image_remote_url/run.ipynb new file mode 100644 index 0000000000..599928e1ed --- /dev/null +++ b/demo/image_remote_url/run.ipynb @@ -0,0 +1 @@ +{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: image_remote_url"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "\n", "\n", "def fn(im):\n", " return im, \"https://picsum.photos/400/300\"\n", "\n", "\n", "demo = gr.Interface(\n", " fn=fn,\n", " inputs=gr.Image(\"https://picsum.photos/300/200\", label=\"InputImage\"),\n", " outputs=[gr.Image(label=\"Loopback\"), gr.Image(label=\"RemoteImage\")],\n", " examples=[\n", " [\"https://picsum.photos/640/480\"]\n", " ]\n", ")\n", "\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()\n"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5} \ No newline at end of file diff --git a/demo/image_remote_url/run.py b/demo/image_remote_url/run.py new file mode 100644 index 0000000000..60de8f9cb5 --- /dev/null +++ b/demo/image_remote_url/run.py @@ -0,0 +1,19 @@ +import gradio as gr + + +def fn(im): + return im, "https://picsum.photos/400/300" + + +demo = gr.Interface( + fn=fn, + inputs=gr.Image("https://picsum.photos/300/200", label="InputImage"), + outputs=[gr.Image(label="Loopback"), gr.Image(label="RemoteImage")], + examples=[ + ["https://picsum.photos/640/480"] + ] +) + + +if __name__ == "__main__": + demo.launch() diff --git a/gradio/processing_utils.py b/gradio/processing_utils.py index 119555d4d8..d82cc37f10 100644 --- a/gradio/processing_utils.py +++ b/gradio/processing_utils.py @@ -16,7 +16,6 @@ from typing import TYPE_CHECKING, Any import aiofiles import httpx import numpy as np -import urllib3 from gradio_client import utils as client_utils from PIL import Image, ImageOps, PngImagePlugin @@ -28,7 +27,76 @@ with warnings.catch_warnings(): warnings.simplefilter("ignore") # Ignore pydub warning if ffmpeg is not installed from pydub import AudioSegment -async_client = httpx.AsyncClient() +if wasm_utils.IS_WASM: + import pyodide.http # type: ignore + import urllib3 + + # NOTE: In the Wasm env, we use urllib3 to make HTTP requests. See https://github.com/gradio-app/gradio/issues/6837. + class Urllib3ResponseSyncByteStream(httpx.SyncByteStream): + def __init__(self, response) -> None: + self.response = response + + def __iter__(self): + yield from self.response.stream() + + class Urllib3Transport(httpx.BaseTransport): + def __init__(self): + self.pool = urllib3.PoolManager() + + def handle_request(self, request: httpx.Request) -> httpx.Response: + url = str(request.url) + method = request.method + headers = dict(request.headers) + body = None if method in ["GET", "HEAD"] else request.read() + + response = self.pool.request( + headers=headers, + method=method, + url=url, + body=body, + preload_content=False, # Stream the content + ) + + return httpx.Response( + status_code=response.status, + headers=response.headers, + stream=Urllib3ResponseSyncByteStream(response), + ) + + sync_transport = Urllib3Transport() + + class PyodideHttpResponseAsyncByteStream(httpx.AsyncByteStream): + def __init__(self, response) -> None: + self.response = response + + async def __aiter__(self): + yield await self.response.bytes() + + class PyodideHttpTransport(httpx.AsyncBaseTransport): + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + url = str(request.url) + method = request.method + headers = dict(request.headers) + body = None if method in ["GET", "HEAD"] else await request.aread() + response = await pyodide.http.pyfetch( + url, method=method, headers=headers, body=body + ) + return httpx.Response( + status_code=response.status, + headers=response.headers, + stream=PyodideHttpResponseAsyncByteStream(response), + ) + + async_transport = PyodideHttpTransport() +else: + sync_transport = None + async_transport = None + +sync_client = httpx.Client(transport=sync_transport) +async_client = httpx.AsyncClient(transport=async_transport) log = logging.getLogger(__name__) @@ -209,15 +277,10 @@ def save_url_to_cache(url: str, cache_dir: str) -> str: full_temp_file_path = str(abspath(temp_dir / name)) if not Path(full_temp_file_path).exists(): - # NOTE: We use urllib3 instead of httpx because it works in the Wasm environment. See https://github.com/gradio-app/gradio/issues/6837. - http = urllib3.PoolManager() - response = http.request( - "GET", - url, - preload_content=False, - ) - with open(full_temp_file_path, "wb") as f: - for chunk in response.stream(): + with sync_client.stream("GET", url, follow_redirects=True) as r, open( + full_temp_file_path, "wb" + ) as f: + for chunk in r.iter_raw(): f.write(chunk) return full_temp_file_path diff --git a/js/app/test/image_remote_url.spec.ts b/js/app/test/image_remote_url.spec.ts new file mode 100644 index 0000000000..179d770314 --- /dev/null +++ b/js/app/test/image_remote_url.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from "@gradio/tootils"; + +test("Image displays remote image correctly", async ({ page }) => { + const example_image = page.locator( + 'div.block:has(div.label:has-text("Examples")) img' + ); + const input_image = page.locator( + 'div.block:has(label:has-text("InputImage")) img' + ); + const loopback_image = page.locator( + 'div.block:has(label:has-text("Loopback")) img' + ); + const remote_output_image = page.locator( + 'div.block:has(label:has-text("RemoteImage")) img' + ); + const submit_button = page.locator('button:has-text("Submit")'); + + await expect(example_image).toHaveJSProperty("complete", true); + await expect(example_image).not.toHaveJSProperty("naturalWidth", 0); + + await expect(input_image).toHaveJSProperty("complete", true); + await expect(input_image).not.toHaveJSProperty("naturalWidth", 0); + + await submit_button.click(); + + await expect(loopback_image).toHaveJSProperty("complete", true); + await expect(loopback_image).not.toHaveJSProperty("naturalWidth", 0); + await expect(remote_output_image).toHaveJSProperty("complete", true); + await expect(remote_output_image).not.toHaveJSProperty("naturalWidth", 0); +});