Files
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Waiting to run
storybook-build / changes (push) Waiting to run
storybook-build / :storybook-build (push) Blocked by required conditions
Sync Gradio Skills to Hugging Face / sync-skills (push) Waiting to run
functional / changes (push) Waiting to run
functional / build-frontend (push) Blocked by required conditions
functional / functional-test-SSR=false (push) Blocked by required conditions
functional / functional-reload (push) Blocked by required conditions
functional / functional-test-SSR=true (push) Blocked by required conditions
hygiene / hygiene-test (push) Waiting to run
python / changes (push) Waiting to run
python / build (push) Blocked by required conditions
python / test-ubuntu-latest-flaky (push) Blocked by required conditions
python / test-ubuntu-latest-not-flaky (push) Blocked by required conditions
python / test-windows-latest-flaky (push) Blocked by required conditions
python / test-windows-latest-not-flaky (push) Blocked by required conditions
js / changes (push) Waiting to run
js / js-test (push) Blocked by required conditions
docs-build / changes (push) Waiting to run
docs-build / docs-build (push) Blocked by required conditions
docs-build / website-build (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

510 lines
20 KiB
Python

import json
import os
import tempfile
from typing import Optional
import pytest
import gradio as gr
import gradio.workflow as workflow_module
from gradio.oauth import OAuthProfile, OAuthToken
from gradio.route_utils import Request
from gradio.workflow import (
WRITE_TOKEN,
Workflow,
_get_locally_saved_hf_token,
_request_has_write_token,
_resolve_token,
_workflow_from_bind,
call_model,
call_space,
get_oauth_available,
get_token,
has_write_access,
)
def _make_oauth(token: str) -> OAuthToken:
obj = OAuthToken.__new__(OAuthToken)
obj.token = token
obj.scope = "openid"
obj.expires_at = 0
return obj
def _make_request(
cookie: str | None = None,
header: str | None = None,
query: str | None = None,
) -> Request:
headers = {}
if cookie is not None:
headers["cookie"] = cookie
if header is not None:
headers["x-gradio-workflow-write-token"] = header
return Request(
headers=headers,
query_params={"write_token": query} if query is not None else {},
)
def _write_request() -> Request:
"""A request that carries the process write token (cookie flavor)."""
return _make_request(cookie=f"gradio_workflow_write_token_7860={WRITE_TOKEN}")
def _shout(text: str) -> str:
return text.upper()
def _add(a: int, b: int) -> int:
return a + b
def _flag(on: bool) -> str:
return "yes" if on else "no"
# ─────────────────────────────────────────────────────────────────────────────
# Construction
# ─────────────────────────────────────────────────────────────────────────────
class TestConstruction:
def test_subclasses_blocks(self):
wf = Workflow(graph=os.path.join(tempfile.gettempdir(), "wf_test.json"))
assert isinstance(wf, gr.Blocks)
def test_default_graph_path_is_caller_dir(self):
wf = Workflow()
assert os.path.basename(wf._workflow_file) == "workflow.json"
# Caller of __init__ is this test file → graph should resolve next to it.
assert os.path.dirname(wf._workflow_file) == os.path.dirname(
os.path.abspath(__file__)
)
def test_graph_parameter_honored(self, tmp_path):
graph = tmp_path / "custom.json"
wf = Workflow(graph=str(graph))
assert wf._workflow_file == str(graph)
def test_bind_accepts_list(self, tmp_path):
wf = Workflow(graph=str(tmp_path / "wf.json"), bind=[_shout, _add])
assert set(wf._bound.keys()) == {"_shout", "_add"}
def test_bind_accepts_dict(self, tmp_path):
wf = Workflow(
graph=str(tmp_path / "wf.json"),
bind={"upper": _shout, "sum": _add},
)
assert set(wf._bound.keys()) == {"upper", "sum"}
def test_workflow_name_derived_from_filename(self, tmp_path):
wf = Workflow(graph=str(tmp_path / "marketing_image_creator.json"))
assert wf._workflow_name == "Marketing Image Creator"
# ─────────────────────────────────────────────────────────────────────────────
# OAuth gating
# ─────────────────────────────────────────────────────────────────────────────
class TestOAuthGating:
def test_no_login_button_off_space(self, tmp_path, monkeypatch):
# Local dev: no Space, no LoginButton, no oauth wiring.
monkeypatch.delenv("SYSTEM", raising=False)
monkeypatch.delenv("SPACE_ID", raising=False)
wf = Workflow(graph=str(tmp_path / "wf.json"))
assert wf.expects_oauth is False
def test_no_login_button_on_space_without_oauth_env(self, tmp_path, monkeypatch):
# The wftest 500 case: on a Space but `hf_oauth: true` either isn't
# set or env vars haven't propagated. Construction must NOT raise.
monkeypatch.setenv("SYSTEM", "spaces")
monkeypatch.setenv("SPACE_ID", "u/r")
monkeypatch.delenv("OAUTH_CLIENT_ID", raising=False)
wf = Workflow(graph=str(tmp_path / "wf.json"))
assert wf.expects_oauth is False
# ─────────────────────────────────────────────────────────────────────────────
# launch() — allowed_paths merging
# ─────────────────────────────────────────────────────────────────────────────
class TestLaunchAllowedPaths:
def test_tempdir_added_by_default(self, tmp_path, monkeypatch):
wf = Workflow(graph=str(tmp_path / "wf.json"))
captured = {}
def fake_super_launch(*args, **kwargs):
captured["allowed_paths"] = kwargs.get("allowed_paths")
return (None, "http://127.0.0.1:7860/", None)
monkeypatch.setattr(gr.Blocks, "launch", fake_super_launch)
wf.launch(prevent_thread_lock=True)
assert tempfile.gettempdir() in captured["allowed_paths"]
def test_user_allowed_paths_preserved(self, tmp_path, monkeypatch):
wf = Workflow(graph=str(tmp_path / "wf.json"))
captured = {}
def fake_super_launch(*args, **kwargs):
captured["allowed_paths"] = kwargs.get("allowed_paths")
return (None, "http://127.0.0.1:7860/", None)
monkeypatch.setattr(gr.Blocks, "launch", fake_super_launch)
wf.launch(allowed_paths=["/extra/path"], prevent_thread_lock=True)
assert "/extra/path" in captured["allowed_paths"]
assert tempfile.gettempdir() in captured["allowed_paths"]
def test_none_allowed_paths_handled(self, tmp_path, monkeypatch):
wf = Workflow(graph=str(tmp_path / "wf.json"))
captured = {}
def fake_super_launch(*args, **kwargs):
captured["allowed_paths"] = kwargs.get("allowed_paths")
return (None, "http://127.0.0.1:7860/", None)
monkeypatch.setattr(gr.Blocks, "launch", fake_super_launch)
wf.launch(allowed_paths=None, prevent_thread_lock=True)
assert captured["allowed_paths"] == [tempfile.gettempdir()]
class TestLaunchWriteTokenLink:
def test_edit_link_printed_locally(self, tmp_path, monkeypatch, capsys):
wf = Workflow(graph=str(tmp_path / "wf.json"))
monkeypatch.setattr(
gr.Blocks,
"launch",
lambda *a, **kw: (None, "http://127.0.0.1:7860/", None),
)
wf.launch(prevent_thread_lock=True)
out = capsys.readouterr().out
assert f"write_token={WRITE_TOKEN}" in out
def test_no_edit_link_on_spaces(self, tmp_path, monkeypatch, capsys):
wf = Workflow(graph=str(tmp_path / "wf.json"))
monkeypatch.setattr(workflow_module, "get_space", lambda: "owner/space")
monkeypatch.setattr(
gr.Blocks,
"launch",
lambda *a, **kw: (None, "http://127.0.0.1:7860/", None),
)
wf.launch(prevent_thread_lock=True)
out = capsys.readouterr().out
assert "write_token" not in out
# ─────────────────────────────────────────────────────────────────────────────
# Token resolution
# ─────────────────────────────────────────────────────────────────────────────
class TestResolveToken:
def test_explicit_data_wins(self, monkeypatch):
monkeypatch.setattr(
workflow_module, "_get_locally_saved_hf_token", lambda: None
)
assert (
_resolve_token(["x", "y", "z", "manual"], 3, _make_oauth("oauth"))
== "manual"
)
def test_oauth_used_when_no_manual(self, monkeypatch):
monkeypatch.setattr(
workflow_module, "_get_locally_saved_hf_token", lambda: None
)
assert _resolve_token([], 3, _make_oauth("oauth-tok")) == "oauth-tok"
def test_local_token_requires_write_access(self, monkeypatch):
# A share-link visitor (no write token) must never see the host's
# locally saved HF token.
monkeypatch.setattr(
workflow_module,
"_get_locally_saved_hf_token",
lambda: "local-tok",
)
assert _resolve_token([], 3, None, _write_request()) == "local-tok"
assert _resolve_token([], 3, None, _make_request()) is None
assert _resolve_token([], 3, None, None) is None
class TestGetToken:
def test_oauth_token_wins(self, monkeypatch):
monkeypatch.setattr(
workflow_module,
"_get_locally_saved_hf_token",
lambda: "local-tok",
)
assert get_token(token=_make_oauth("oauth-tok")) == "oauth-tok"
def test_local_token_requires_write_access(self, monkeypatch):
monkeypatch.setattr(
workflow_module,
"_get_locally_saved_hf_token",
lambda: "local-tok",
)
assert get_token(request=_write_request()) == "local-tok"
assert get_token(request=_make_request()) == ""
assert get_token() == ""
def test_local_hf_token_is_disabled_on_spaces(self, monkeypatch):
monkeypatch.setattr(workflow_module, "get_space", lambda: "owner/space")
assert _get_locally_saved_hf_token() is None
class TestOAuthAvailable:
# Drives whether the frontend shows the "Sign in" button: OAuth is only
# wired up on a Space with hf_oauth enabled (OAUTH_CLIENT_ID provisioned).
def test_false_locally(self, monkeypatch):
monkeypatch.setattr(workflow_module, "get_space", lambda: None)
assert get_oauth_available() == "false"
def test_false_on_space_without_oauth(self, monkeypatch):
monkeypatch.setattr(workflow_module, "get_space", lambda: "owner/space")
monkeypatch.delenv("OAUTH_CLIENT_ID", raising=False)
assert get_oauth_available() == "false"
def test_true_on_space_with_oauth(self, monkeypatch):
monkeypatch.setattr(workflow_module, "get_space", lambda: "owner/space")
monkeypatch.setenv("OAUTH_CLIENT_ID", "client-id")
assert get_oauth_available() == "true"
# ─────────────────────────────────────────────────────────────────────────────
# Write access — local write token + Spaces OAuth ownership
# ─────────────────────────────────────────────────────────────────────────────
class TestWriteAccess:
def test_token_accepted_via_header_cookie_or_query(self):
assert _request_has_write_token(_make_request(header=WRITE_TOKEN))
assert _request_has_write_token(_write_request())
assert _request_has_write_token(_make_request(query=WRITE_TOKEN))
# Frontend suffixes the cookie name with the port; any suffix works.
assert _request_has_write_token(
_make_request(
cookie=f"other=1; gradio_workflow_write_token_8080={WRITE_TOKEN}"
)
)
def test_wrong_or_missing_token_denied(self):
assert not _request_has_write_token(
_make_request(cookie="gradio_workflow_write_token_7860=wrong")
)
assert not _request_has_write_token(_make_request(header="wrong"))
assert not _request_has_write_token(_make_request(query="wrong"))
assert not _request_has_write_token(_make_request())
assert not _request_has_write_token(None)
def test_write_token_ignored_on_spaces(self, monkeypatch):
# On Spaces only OAuth ownership grants write access; a leaked write
# token must not.
monkeypatch.setattr(workflow_module, "get_space", lambda: "owner/space")
assert has_write_access(_write_request(), None) is False
class TestSaveWorkflowGating:
def _save_fn(self, tmp_path):
wf = Workflow(graph=str(tmp_path / "wf.json"))
canvas = next(
b for b in wf.blocks.values() if b.get_block_name() == "workflowcanvas"
)
return canvas.save_workflow
def test_save_rejected_without_write_access(self, tmp_path):
save = self._save_fn(tmp_path)
result = json.loads(save(['{"nodes": []}'], _make_request(), None))
assert result["error_type"] == "auth"
assert not os.path.exists(tmp_path / "wf.json")
def test_save_allowed_with_write_access(self, tmp_path):
save = self._save_fn(tmp_path)
# A valid schema_version 2 payload (save now validates the schema).
assert save(['{"schema_version": "2"}'], _write_request(), None) == "ok"
assert os.path.exists(tmp_path / "wf.json")
# ─────────────────────────────────────────────────────────────────────────────
# _workflow_from_bind — graph generation from Python functions
# ─────────────────────────────────────────────────────────────────────────────
class TestWorkflowFromBind:
def test_generates_nodes_for_each_bound_fn(self):
result = json.loads(_workflow_from_bind({"shout": _shout, "add": _add}))
fns = {n["fn"] for n in result["nodes"]}
assert fns == {"shout", "add"}
def test_port_type_from_annotation(self):
result = json.loads(_workflow_from_bind({"add": _add}))
node = result["nodes"][0]
assert all(p["type"] == "number" for p in node["inputs"])
def test_bool_annotation_maps_to_boolean(self):
result = json.loads(_workflow_from_bind({"flag": _flag}))
node = result["nodes"][0]
assert node["inputs"][0]["type"] == "boolean"
def test_unannotated_param_defaults_to_text(self):
def plain(x): # no annotation
return x
result = json.loads(_workflow_from_bind({"plain": plain}))
node = result["nodes"][0]
assert node["inputs"][0]["type"] == "text"
def test_edges_resolve_by_function_name(self):
result = json.loads(
_workflow_from_bind(
{"shout": _shout, "add": _add},
edges=[("shout", "add")],
)
)
assert len(result["edges"]) == 1
edge = result["edges"][0]
assert edge["from_node_id"] == "fn_shout"
assert edge["to_node_id"] == "fn_add"
def test_edges_resolve_by_port_label(self):
result = json.loads(
_workflow_from_bind(
{"add": _add, "shout": _shout},
edges=[("add.output", "shout.text")],
)
)
edge = result["edges"][0]
assert edge["from_port_id"] == "out_0"
assert edge["to_port_id"] == "in_text"
def test_unknown_function_in_edge_raises(self):
with pytest.raises(ValueError, match="no function 'nope'"):
_workflow_from_bind({"shout": _shout}, edges=[("nope", "shout")])
def test_unknown_port_label_raises(self):
with pytest.raises(ValueError, match="no .* port"):
_workflow_from_bind(
{"shout": _shout}, edges=[("shout.bogus", "shout.text")]
)
def test_zero_input_fn_gets_synthetic_input_port(self):
def noargs() -> str:
return ""
result = json.loads(_workflow_from_bind({"noargs": noargs}))
node = result["nodes"][0]
assert node["inputs"] == [{"id": "in_0", "label": "input", "type": "text"}]
class TestCallModelValidation:
def test_url_shaped_model_id_is_rejected(self):
result = json.loads(call_model(["http://169.254.169.254/latest/meta-data/"]))
assert result.get("error_type") == "not_found"
def test_https_url_is_rejected(self):
result = json.loads(call_model(["https://attacker.example.com/endpoint"]))
assert result.get("error_type") == "not_found"
def test_empty_model_id_is_rejected(self):
result = json.loads(call_model([""]))
assert result.get("error_type") == "not_found"
def test_bare_repo_name_is_rejected(self):
result = json.loads(call_model(["gpt2"]))
assert result.get("error_type") == "not_found"
def test_valid_owner_repo_passes_validation(self, monkeypatch):
class FakeClient:
def __init__(self, **kwargs):
pass
def text_generation(self, *args, **kwargs):
return "hello"
monkeypatch.setattr(
"gradio.workflow.InferenceClient", FakeClient, raising=False
)
import gradio.workflow as wf
wf.call_model.__globals__.get("InferenceClient")
import sys
import types
fake_hf = types.ModuleType("huggingface_hub")
fake_hf.InferenceClient = FakeClient # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "huggingface_hub", fake_hf)
result = json.loads(call_model(["owner/model"]))
assert "error" not in result
class TestCallFn:
def _call_fn(self, tmp_path, bind):
wf = Workflow(graph=str(tmp_path / "wf.json"), bind=bind)
canvas = next(
b for b in wf.blocks.values() if b.get_block_name() == "workflowcanvas"
)
return canvas.call_fn
def test_calls_bound_fn(self, tmp_path):
call_fn = self._call_fn(tmp_path, {"echo": lambda x: x})
result = json.loads(call_fn(["echo", '["hello"]']))
assert result == ["hello"]
def test_injects_oauth_token(self, tmp_path):
received = {}
def fn_with_token(text: str, token: Optional[OAuthToken]) -> str:
received["token"] = token
return text
call_fn = self._call_fn(tmp_path, {"fn_with_token": fn_with_token})
class _MockRequest:
session = {
"oauth_info": {
"access_token": "test-tok",
"scope": "openid",
"expires_at": 9999999999,
}
}
result = json.loads(
call_fn(["fn_with_token", '["hi"]'], _request=_MockRequest())
)
assert result == ["hi"]
assert received["token"].token == "test-tok"
def test_injects_direct_token_without_session(self, tmp_path):
def fn(text: str, token: OAuthToken, profile: Optional[OAuthProfile]) -> str:
return f"{token.token},{profile}"
call_fn = self._call_fn(tmp_path, {"fn": fn})
direct = OAuthToken.__new__(OAuthToken)
direct.token = "direct-token"
direct.scope = "openid"
direct.expires_at = 9999999999
result = json.loads(call_fn(["fn", '["hello"]'], _request=None, _token=direct))
assert result == ["direct-token,None"]
def test_unknown_fn_returns_error(self, tmp_path):
call_fn = self._call_fn(tmp_path, {"echo": lambda x: x})
result = json.loads(call_fn(["missing", "[]"]))
assert result.get("error_type") == "unknown"
class TestCallSpaceValidation:
def test_url_shaped_space_id_is_rejected(self):
result = json.loads(call_space(["http://169.254.169.254/"]))
assert result.get("error_type") == "not_found"
def test_valid_owner_repo_format_accepted_by_regex(self):
import re
pattern = r"[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+"
assert re.fullmatch(pattern, "owner/repo")
assert re.fullmatch(pattern, "my-org/my-space")
assert re.fullmatch(pattern, "http://host/path") is None