Fix private Space file URLs in gr.load() and the JS client - #13816
Fix private Space file URLs in gr.load() and the JS client#13816abidlabs wants to merge 8 commits into
Conversation
🪼 branch checks and previews
Install Gradio from this PR pip install https://huggingface.co/buckets/gradio/pypi-previews/resolve/490ab3ecfc2d2fb13cfbec5bd62a01527f0f95be/gradio-6.26.0-py3-none-any.whlInstall Gradio Python Client from this PR pip install "gradio-client @ git+https://github.com/gradio-app/gradio@490ab3ecfc2d2fb13cfbec5bd62a01527f0f95be#subdirectory=client/python"Import Gradio JS Client from this PR via CDN import { Client } from "https://huggingface.co/buckets/gradio/npm-previews/resolve/490ab3ecfc2d2fb13cfbec5bd62a01527f0f95be/browser.js"; |
🦄 change detectedThis Pull Request includes changes to the following packages.
|
Before / after Spaces
Both Spaces load the same private source and use byte-identical loader The first section exercises
Live verification used the exact PR JS preview and confirmed that external URLs remain unchanged while all private same-origin file cases above are signed. |
There was a problem hiding this comment.
🟢 Approval recommended
The implementation and tests are sound; the remaining changeset comment is a non-blocking nit.
Pull request overview
Fixes authenticated file proxying for private Spaces loaded via gr.load().
Changes:
- Preserves upstream paths for loaded Space files.
- Keeps external HTTP URLs direct.
- Adds synchronous and asynchronous regression tests.
File summaries
| File | Description |
|---|---|
test/test_processing_utils.py |
Adds regression coverage for private files and external URLs. |
gradio/processing_utils.py |
Corrects loaded-Space proxy URL construction. |
.changeset/silly-hairs-tie.md |
Adds a hand-authored changeset that should be removed. |
Review details
Suppressed comments (1)
.changeset/silly-hairs-tie.md:5
- Remove this hand-authored changeset.
AGENTS.md:39states that the GitHub Action generates changesets from the PR title; keeping this file causes it to override the generated changelog entry.
---
"gradio": patch
---
fix:Fix private Space file proxying in gr.load()
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
hysts
left a comment
There was a problem hiding this comment.
The double /file= is the bug and the fix is in the right place. Repro pair on this wheel, if it is useful: gradio-13816-loader is a bare gr.load() of the private gradio-13816-private-source. Viewed logged out, the bottom row fills correctly when you press the button, so the click path works.
Two things I think should be fixed before this lands. The first is inline: once payload.path keeps the upstream filesystem path, any value travelling back as an input reaches gradio_client._upload_file as if it were a local file, so any second event fails. Not private-specific, and there is a traceback from that same Space. The second is below, because gradio/blocks.py is not in the diff and there is no line to anchor to. Two smaller points are inline as well.
Initial values still carry the raw private URL. Blocks.from_config pops value before construction and assigns it straight back afterwards, so it never reaches move_files_to_cache:
Lines 1276 to 1284 in a0e1111
A private Space with gr.Image(value=...) or a pre-filled gr.Gallery still shows a logged-out viewer a broken image after this PR. That is the top row in the loader above, which stays broken next to the bottom row that this PR fixed. Pre-filled galleries as inputs are common enough that I would not call it an edge case.
Your change is what makes that fixable. Running the value through move_files_to_cache once proxy_url is set, at line 1284 above, gives the correct single-proxy URL on top of this PR, and the old double-proxy without it:
block_proxy_url = block_config["props"]["proxy_url"]
block.proxy_url = block_proxy_url
if postprocessed_value is not None:
block.value = processing_utils.move_files_to_cache(
block.value, block, postprocess=True
)The JS client case is untouched, and not something for this PR. Poli reported the same symptom through @gradio/client on the issue. Nothing in processing_utils reaches it: the client has no server in the loop to re-sign the request, which is exactly what makes the Python fix work. Client.fetch() already attaches the token, so a consumer can fetch and build a blob URL today, but there is no first-party answer. Flagging it so it does not get lost, whether that means a separate issue or something in this one.
| payload.url | ||
| and postprocess | ||
| and client_utils.is_http_url_like(payload.url) | ||
| and not block.proxy_url |
There was a problem hiding this comment.
Right for display, but it also changes what the next event receives.
from_config sets dependency["preprocess"] = False, so preprocess_data passes inputs_serialized to the client end-to-end fn verbatim, reaching Endpoint.process_input_files and then _upload_file. That skips everything when the path is an http URL and otherwise treats it as a local file. Before this PR path was the absolute upstream URL, so the local branch never ran and the upstream re-fetched the file. Now path belongs to another container.
Live, on the loader Space linked above. /fill returns the corrected URL:
path = /tmp/gradio/916cf043.../cheetah1.jpg
url = .../gradio_api/proxy=https://<source>.hf.space/gradio_api/file=/tmp/gradio/916cf043.../cheetah1.jpg
Handing that same payload to /describe, which takes that component as an input and is what the browser does on any second event:
File ".../gradio_client/client.py", line 1382, in _upload_file
if os.path.getsize(file_path) > max_file_size:
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/gradio/916cf043.../cheetah1.jpg'
Public loaded Spaces hit this too: the condition is that the two apps do not share a filesystem. It passes locally, where both sides are one machine and the path exists, which is what makes it easy to miss.
Same root cause, narrower consequence: .select() handlers go through make_event_data_fn, and special_args runs check_all_files_in_cache, which passes http-like paths and otherwise requires the path to sit under get_upload_folder(). Measured with both payload shapes:
loader GRADIO_TEMP_DIR=/tmp/gradio before=ok after=ok
loader GRADIO_TEMP_DIR=/data/gradio before=ok after=raises
loader GRADIO_TEMP_DIR=/var/folders/../T/… before=ok after=raises
Space-to-Space is fine; gr.load() from macOS, or anything setting GRADIO_TEMP_DIR, raises "File ... is not in the cache folder" on every selection.
One shape that keeps both directions: restore payload.path as you do here, keep payload.url too, and let _upload_file prefer the URL when there is one. Here or in the client, your call.
Minor, while you are in this hunk: the comment above still explains only the original three conjuncts. A line saying a proxy_url means the URL came from the upstream app rather than the developer, and is not browser-reachable for a private Space, would save the next reader a trip through from_config.
| f"{API_PREFIX}/stream/" if payload.is_stream else f"{API_PREFIX}/file=" | ||
| ) | ||
| if block.proxy_url: | ||
| if block.proxy_url and not client_utils.is_http_url_like(payload.path): |
There was a problem hiding this comment.
This changes who fetches external URLs. Before, an external URL on a proxied component went out as /gradio_api/proxy=<space>/gradio_api/file=<url>, so the upstream fetched it through secure_url_stream_response and the bytes reached the viewer through two servers. Now the URL is emitted verbatim and the viewer's browser contacts that host directly, with the usual IP and referrer exposure.
I do not think that is wrong. It is what a plain app does for gr.Image("https://..."), and the old behaviour only existed as a side effect of the double-proxy bug. But test_move_files_to_cache_does_not_proxy_external_urls locks it in as intended, so it is worth a sentence in the description and a comment here rather than arriving silently.
| ) | ||
|
|
||
| for result in (sync_result, async_result): | ||
| assert result["path"] == remote_path |
There was a problem hiding this comment.
Both new tests are postprocess=True only, which is the half that works. Inline 1 lives in the direction they do not cover, and neither reaches a gr.load() round trip, since proxy_url is set on the component by hand rather than through Blocks.from_config.
Two additions would have caught it: feed result back through async_move_files_to_cache(..., postprocess=False) and assert check_all_files_in_cache(result) does not raise with GRADIO_TEMP_DIR pointed away from /tmp/gradio; and assert what Endpoint.process_input_files does with result.
Also worth three lines: is_stream=True. The /gradio_api/stream/ prefix goes through the same condition and was double-proxied the same way, so this fixes streaming outputs from a loaded Space too, currently unguarded.
…t-load-images-from-private-space-with-gr-lo
|
Hmm I see thanks @hysts let me convert this back to draft while I investigate if there's a better way |
…t-load-images-from-private-space-with-gr-lo
Description
Private-Space file values now work consistently through both
gr.load()and@gradio/client, including initial component values, event outputs, values reused by later events, Gallery data, and streaming media.For
gr.load(), upstream files keep their remote filesystempathwhile exposing one authenticated loader-proxy URL to the browser. Initial component values are normalized afterproxy_urlis installed, and browser-facing proxy URLs are converted back to upstream inputs for later events. Explicit external HTTP URLs remain direct.For
@gradio/client, returnedgradio.FileDataURLs on the connected private Space's own origin receive the scoped Space JWT already obtained during connection. This is applied to initial/refreshed config, prediction and streaming data, render events, and component-server outputs. External URLs are intentionally unchanged, and the upstreampathis retained so outputs can be reused as inputs. Signed HLS playlist requests also propagate the signature to their segment URLs, since media players do not inherit the playlist query string automatically.Closes: #11605
AI Disclosure
🎯 PRs Should Target Issues
This PR targets #11605. I checked for overlapping open PRs by issue number and key terms and found none.
Testing and Formatting Your Code
client/jsbrowser suite — 189 passed, 10 skippedclient/jsNode suite — 174 passed, 25 skippedtest_processing_utils.py,test_blocks.py,test_helpers.py, andtest_routes.py— 396 passedpnpm build, Prettier, ESLint, TypeScript, and focused Ruff checks — passed__signand fetches without a bearer header (200 image/png)Received the image back as an input: 480×320__signand fetch without bearer headers (200)480×320Received the image back as an input: 480×3204The demo below is kept untracked and is also deployed as the linked before/after Spaces.