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
510 lines
20 KiB
Python
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
|