Repository: gradio-app/gradio
Pull request: Fix `gr.load` for file-based Spaces
There were two separate issues going on:

1. We weren't handling  URLs correctly in the Client. In particular, we would transform any string that was a valid URL to a dictionary consisting of a path regardless of whether the value corresponded to a file-based component. Now, we check to see if the component is a file-based component. This is still not totally robust, as we should actually inspect the typing information to see which part of the payload is actually the file, but getting that working is a bit tricky. For now, this should be a good enough solution and I've created a [new issue to track](https://github.com/gradio-app/gradio/issues/7360). To skip conversion, one can still set `serialize=False`.

2. When we loaded a Space using `gr.load()` and that Space had output files, we were unnecessarily copying them locally and serving the local files. This is not only inefficient but also doesn't work well with some components after the normalize_files refactor. Now we simply serve the files directly from the remote Space using the `/proxy` route if `deserialize=False`, which is a new parameter in `Client` (I considered other ways to implement this, including changing the behavior of `serialize` to cover this, but all led to other side effects or breaking changes).

Closes: #6744

The original Space doesn't have a repro but you can try this:

```py
import gradio as gr

gr.load("gradio-tests/space_with_files_v4_sse_v2", src="spaces").launch()
```

I also tested this with a private version of the same Space, but it'd be great to test with a few other Spaces to make sure `gr.load()` is working as expected. 

Problem statement:
A Space loaded with `gr.load()` is not functional
### Describe the bug

While embedding the private space into the public one, UI is transitioned, but is completely unfunctional, although the private space by itself works perfectly. Dropdowns can't be clicked, buttons don't do anything, one can only fill a text field successfully. Also, no error log is shown.
I am embedding space as I currently don't want to share my code, hence private space load from public space.

### Have you searched existing issues?  🔎

- [x] I have searched and found no existing issues

### Reproduction

```python
import gradio as gr
import os

HF_TOKEN = os.environ["HF_TOKEN"]

css = """
.column-frame {
    border: 2px solid #AAA;
    border-radius: 10px;
    padding: 10px;
    margin: 10px;
}
"""

with gr.Blocks(theme=gr.themes.Soft(),css=css) as demo:
    gr.load("private_space_path", src="spaces", hf_token=HF_TOKEN)

demo.launch(debug=True)
```

### Screenshot

_No response_

### Logs

_No response_

### System Info

```shell
gradio=4.8.0
env=Hugging Face Spaces
```


### Severity

Blocking usage of gradio

Hints from issue discussion:
Hi @vitaliy-sharandin do you see any errors in the logs? 
As I've written before, no error logs are shown, it just silently doesn't work, there are dropdowns/video upload/buttons failing and from what I've seen in the HF forums, some users experienced troubles with data in loading spaces as well. Again, when private one works fine. Here is my public space, you can see for yourself.

I've also tried switching my public space to GPU configuration as private one requires it, still no success. Should this be done at all or it's ok to use CPU config in public space while private is GPU dependent?

https://huggingface.co/spaces/vitaliy-sharandin/Video_translation_with_speaker_diarization_and_voice_cloning
Thanks @vitaliy-sharandin. The public Space does not need a GPU, it calls the private one via API. I'll take a look!
Thanks, @abidlabs, also, please let me know in this thread when you finish looking at project so that I turn off the private space, as GPU is eating money away.
Oh please don't keep the GPU running. I would at least set the sleep time to a small amount, like 5 minutes (you can do this in the settings). 
I understand, but before when the private space was asleep, the public one threw exception about inaccessibility, strange, now it's showing ui for some reason. Now the private is asleep, tell me if you'd need it up.
@vitaliy-sharandin could I ask you to test something -- does this issue persist if you make your private Space public? I understand that you may not want to make your full private Space public, but if you could try with a similar Space, that would help us figure out if the issue specifically happens with private Spaces, or with `gr.load()` generally.

If its the latter (happening with `gr.load()` generally), then I think I know the issue
Yes, the issue persists when you load public space as well.
Cool thanks for letting us know. Will work on a fix
@abidlabs Thanks for fixing the issue. Could you either create an official release or tell me how to update Spaces gradio version from repository main branch?

Commit message: add changeset

Patch to review:
diff --git a/.changeset/tender-lamps-shout.md b/.changeset/tender-lamps-shout.md
new file mode 100644
index 0000000000..5ae5385e0a
--- /dev/null
+++ b/.changeset/tender-lamps-shout.md
@@ -0,0 +1,6 @@
+---
+"gradio": patch
+"gradio_client": patch
+---
+
+fix:Fix `gr.load` for file-based Spaces
diff --git a/client/python/gradio_client/client.py b/client/python/gradio_client/client.py
index 9abee7f2a2..1b6179e2d2 100644
--- a/client/python/gradio_client/client.py
+++ b/client/python/gradio_client/client.py
@@ -77,20 +77,23 @@ def __init__(
         auth: tuple[str, str] | None = None,
         *,
         headers: dict[str, str] | None = None,
+        deserialize: bool = True,
     ):
         """
         Parameters:
             src: Either the name of the Hugging Face Space to load, (e.g. "abidlabs/whisper-large-v2") or the full URL (including "http" or "https") of the hosted Gradio app to load (e.g. "http://mydomain.com/app" or "https://bec81a83-5b5c-471e.gradio.live/").
             hf_token: The Hugging Face token to use to access private Spaces. Automatically fetched if you are logged in via the Hugging Face Hub CLI. Obtain from: https://huggingface.co/settings/token
             max_workers: The maximum number of thread workers that can be used to make requests to the remote Gradio app simultaneously.
-            serialize: Whether the client should serialize the inputs and deserialize the outputs of the remote API. If set to False, the client will pass the inputs and outputs as-is, without serializing/deserializing them. E.g. you if you set this to False, you'd submit an image in base64 format instead of a filepath, and you'd get back an image in base64 format from the remote API instead of a filepath.
+            serialize: Whether the client should treat input string filepath as files and upload them to the remote server. If False, the client will treat input string filepaths as strings always and not modify them.
             output_dir: The directory to save files that are downloaded from the remote API. If None, reads from the GRADIO_TEMP_DIR environment variable. Defaults to a temporary directory on your machine.
             verbose: Whether the client should print statements to the console.
             headers: Additional headers to send to the remote Gradio app on every request. By default only the HF authorization and user-agent headers are sent. These headers will override the default headers if they have the same keys.
+            deserialize: Whether the client should download output files from the remote API and return them as string filepaths on the local machine. If False, the client will a FileData dataclass object with the filepath on the remote machine instead.
         """
         self.verbose = verbose
         self.hf_token = hf_token
         self.serialize = serialize
+        self.deserialize = deserialize
         self.headers = build_hf_headers(
             token=hf_token,
             library_name="gradio_client",
@@ -955,7 +958,11 @@ def _get_component_type(self, component_id: int):
 
     @staticmethod
     def value_is_file(component: dict) -> bool:
-        # Hacky for now
+        # This is still hacky as it does not tell us which part of the payload is a file.
+        # If a component has a complex payload, part of which is a file, this will simply
+        # return True, which means that all parts of the payload will be uploaded as files
+        # if they are valid file paths. The better approach would be to traverse the
+        # component's api_info and figure out exactly which part of the payload is a file.
         if "api_info" not in component:
             return False
         return utils.value_is_file(component["api_info"])
@@ -1117,6 +1124,9 @@ def get_file(d):
                 file_list.append(d)
             return ReplaceMe(len(file_list) - 1)
 
+        def handle_url(s):
+            return {"path": s, "orig_name": s.split("/")[-1]}
+
         new_data = []
         for i, d in enumerate(data):
             if self.input_component_types[i].value_is_file:
@@ -1126,6 +1136,8 @@ def get_file(d):
                 d = utils.traverse(
                     d, get_file, lambda s: utils.is_file_obj(s) or utils.is_filepath(s)
                 )
+                # Handle URLs here since we don't upload them
+                d = utils.traverse(d, handle_url, lambda s: utils.is_url(s))
             new_data.append(d)
         return file_list, new_data
 
@@ -1146,11 +1158,6 @@ def serialize(self, *data) -> tuple:
         uploaded_files = self._upload(files)
         data = list(new_data)
         data = self._add_uploaded_files_to_data(data, uploaded_files)
-        data = utils.traverse(
-            data,
-            lambda s: {"path": s},
-            utils.is_url,
-        )
         o = tuple(data)
         return o
 
@@ -1182,12 +1189,12 @@ def _download_file(
 
     def deserialize(self, *data) -> tuple:
         data_ = list(data)
-
         data_: list[Any] = utils.traverse(data_, self.download_file, utils.is_file_obj)
         return tuple(data_)
 
     def process_predictions(self, *predictions):
-        predictions = self.deserialize(*predictions)
+        if self.client.deserialize:
+            predictions = self.deserialize(*predictions)
         predictions = self.remove_skipped_components(*predictions)
         predictions = self.reduce_singleton_output(*predictions)
         return predictions
diff --git a/client/python/gradio_client/utils.py b/client/python/gradio_client/utils.py
index 8c3898a74b..296f46e1d4 100644
--- a/client/python/gradio_client/utils.py
+++ b/client/python/gradio_client/utils.py
@@ -895,6 +895,7 @@ def get_type(schema: dict):
         raise APIInfoParseError(f"Cannot parse type for {schema}")
 
 
+OLD_FILE_DATA = "Dict(path: str, url: str | None, size: int | None, orig_name: str | None, mime_type: str | None)"
 FILE_DATA = "Dict(path: str, url: str | None, size: int | None, orig_name: str | None, mime_type: str | None, is_stream: bool)"
 
 
@@ -995,7 +996,7 @@ def traverse(json_obj: Any, func: Callable, is_root: Callable) -> Any:
 
 def value_is_file(api_info: dict) -> bool:
     info = _json_schema_to_python_type(api_info, api_info.get("$defs"))
-    return FILE_DATA in info
+    return FILE_DATA in info or OLD_FILE_DATA in info
 
 
 def is_filepath(s):
diff --git a/client/python/test/test_client.py b/client/python/test/test_client.py
index d0017422c8..a7bb5dea6a 100644
--- a/client/python/test/test_client.py
+++ b/client/python/test/test_client.py
@@ -133,6 +133,28 @@ def test_private_space_v4_sse_v1(self):
         output = client.predict("abc", api_name="/predict")
         assert output == "abc"
 
+    @pytest.mark.flaky
+    def test_space_with_files_v4_sse_v2(self):
+        space_id = "gradio-tests/space_with_files_v4_sse_v2"
+        client = Client(space_id)
+        payload = (
+            "https://audio-samples.github.io/samples/mp3/blizzard_unconditional/sample-0.mp3",
+            {
+                "video": "https://github.com/gradio-app/gradio/raw/main/demo/video_component/files/world.mp4",
+                "subtitle": None,
+            },
+            "https://audio-samples.github.io/samples/mp3/blizzard_unconditional/sample-0.mp3",
+        )
+        output = client.predict(*payload, api_name="/predict")
+        assert output[0].endswith(".wav")  # Audio files are converted to wav
+        assert output[1]["video"].endswith(
+            "world.mp4"
+        )  # Video files are not converted by default
+        assert (
+            output[2]
+            == "https://audio-samples.github.io/samples/mp3/blizzard_unconditional/sample-0.mp3"
+        )  # textbox string should remain exactly the same
+
     def test_state(self, increment_demo):
         with connect(increment_demo) as client:
             output = client.predict(api_name="/increment_without_queue")
diff --git a/gradio/components/video.py b/gradio/components/video.py
index 5b9eef4020..3f531add9c 100644
--- a/gradio/components/video.py
+++ b/gradio/components/video.py
@@ -350,4 +350,7 @@ def srt_to_vtt(srt_file_path, vtt_file_path):
         return FileData(path=str(subtitle))
 
     def example_inputs(self) -> Any:
-        return "https://github.com/gradio-app/gradio/raw/main/demo/video_component/files/world.mp4"
+        return {
+            "video": "https://github.com/gradio-app/gradio/raw/main/demo/video_component/files/world.mp4",
+            "subtitles": None,
+        }
diff --git a/gradio/external.py b/gradio/external.py
index 23b21a4ead..1ea986a062 100644
--- a/gradio/external.py
+++ b/gradio/external.py
@@ -418,12 +418,16 @@ def from_spaces(
 
 
 def from_spaces_blocks(space: str, hf_token: str | None) -> Blocks:
-    client = Client(space, hf_token=hf_token)
+    client = Client(space, hf_token=hf_token, deserialize=False)
+    # We set deserialize to False to avoid downloading output files from the server.
+    # Instead, we serve them as URLs using the /proxy/ endpoint directly from the server.
+
     if client.app_version < version.Version("4.0.0b14"):
         raise GradioVersionIncompatibleError(
             f"Gradio version 4.x cannot load spaces with versions less than 4.x ({client.app_version})."
             "Please downgrade to version 3 to load this space."
         )
+
     # Use end_to_end_fn here to properly upload/download all files
     predict_fns = []
     for fn_index, endpoint in enumerate(client.endpoints):
diff --git a/gradio/processing_utils.py b/gradio/processing_utils.py
index 34023819ea..fb1794f2e7 100644
--- a/gradio/processing_utils.py
+++ b/gradio/processing_utils.py
@@ -261,22 +261,24 @@ def _move_to_cache(d: dict):
         # without it being served from the gradio server
         # This makes it so that the URL is not downloaded and speeds up event processing
         if payload.url and postprocess:
-            temp_file_path = payload.url
-        else:
+            payload.path = payload.url
+        elif not block.proxy_url:
+            # If the file is on a remote server, do not move it to cache.
             temp_file_path = move_resource_to_block_cache(payload.path, block)
-        assert temp_file_path is not None
-        payload.path = temp_file_path
+            assert temp_file_path is not None
+            payload.path = temp_file_path
 
         if add_urls:
             url_prefix = "/stream/" if payload.is_stream else "/file="
             if block.proxy_url:
-                url = f"/proxy={block.proxy_url}{url_prefix}{temp_file_path}"
-            elif client_utils.is_http_url_like(
-                temp_file_path
-            ) or temp_file_path.startswith(f"{url_prefix}"):
-                url = temp_file_path
+                proxy_url = block.proxy_url.rstrip("/")
+                url = f"/proxy={proxy_url}{url_prefix}{payload.path}"
+            elif client_utils.is_http_url_like(payload.path) or payload.path.startswith(
+                f"{url_prefix}"
+            ):
+                url = payload.path
             else:
-                url = f"{url_prefix}{temp_file_path}"
+                url = f"{url_prefix}{payload.path}"
             payload.url = url
 
         return payload.model_dump()


Code review comments:
diff_hunk: @@ -77,20 +77,23 @@ def __init__(
         auth: tuple[str, str] | None = None,
         *,
         headers: dict[str, str] | None = None,
+        deserialize: bool = True,
path: client/python/gradio_client/client.py
text: @user1:
Should we call this download_files?

@author:
Yeah I agree that's a more intuitive name. I'll rename serialize to download_files as well (and soft-deprecate serialize)

@author:
Made the change @user1, will merge in after CI