1010import secrets
1111import sys
1212import tempfile
13+ import threading
1314import types
1415import urllib .parse
1516import warnings
1819from concurrent .futures import ThreadPoolExecutor
1920from typing import TYPE_CHECKING , Optional , TypedDict , Union , get_type_hints
2021
22+ import anyio
2123import httpx
2224from huggingface_hub import HfApi
2325from huggingface_hub import get_token as hf_get_token
2426
2527import gradio as gr
2628from gradio .blocks import Blocks
2729from gradio .components .workflowcanvas import WorkflowCanvas
30+ from gradio .context import Context
2831from gradio .helpers import special_args as _special_args
2932from gradio .oauth import OAuthProfile , OAuthToken
3033from gradio .route_utils import Request
@@ -67,7 +70,7 @@ class _CuratedCache(TypedDict):
6770
6871
6972_CURATED_CACHE : _CuratedCache = {"fetched_at" : 0.0 , "items" : None }
70- _CURATED_LOCK = __import__ ( " threading" ) .Lock ()
73+ _CURATED_LOCK = threading .Lock ()
7174
7275
7376def _bundled_snapshot_path () -> str :
@@ -135,6 +138,7 @@ def _load_curated() -> list[dict]:
135138# Scalar-only — everything else (str, list, dict, custom classes) falls through
136139# to the default "text" port type, which round-trips as JSON.
137140_PY_TO_PORT = {int : "number" , float : "number" , bool : "boolean" }
141+ _SANITIZE_RE = re .compile (r"[^a-zA-Z0-9_-]" )
138142
139143
140144def _build_edges (
@@ -1304,6 +1308,11 @@ def __init__(
13041308 self ._bound : dict [str , Callable ] = bind or {}
13051309 self ._edges : list [tuple [str , str ]] = edges or []
13061310
1311+ if Context .root_block is not None :
1312+ raise ValueError (
1313+ "gr.Workflow cannot be created inside another gr.Blocks context."
1314+ )
1315+
13071316 warnings .warn (
13081317 "gr.Workflow is currently in beta. Its API and UX may change in future releases." ,
13091318 UserWarning ,
@@ -1338,11 +1347,10 @@ def _load_initial() -> str | None:
13381347 return None
13391348
13401349 bound = self ._bound
1350+ _save_lock = threading .Lock ()
13411351
13421352 def call_fn (
1343- data ,
1344- _request : Optional [Request ] = None ,
1345- _token : Optional [OAuthToken ] = None ,
1353+ data , request : Optional [Request ] = None , token : Optional [OAuthToken ] = None
13461354 ) -> str :
13471355 fn_name = data [0 ] if data else ""
13481356 try :
@@ -1359,10 +1367,15 @@ def call_fn(
13591367 args = json .loads (args_json )
13601368 if not isinstance (args , list ):
13611369 args = [args ]
1362- args , * _ = _special_args (fn , args , _request , None , token = _token )
1363- result = fn (* args )
1364- result = list (result ) if isinstance (result , (list , tuple )) else [result ]
1365- return json .dumps (result )
1370+ args , * _ = _special_args (fn , args , request , None , token = token )
1371+ if inspect .iscoroutinefunction (fn ):
1372+ import asyncio
1373+
1374+ result = asyncio .run (fn (* args ))
1375+ else :
1376+ result = fn (* args )
1377+ out = list (result ) if isinstance (result , (list , tuple )) else [result ]
1378+ return json .dumps (out )
13661379 except Exception as e :
13671380 logger .error ("call_fn failed for %s: %s" , fn_name , e , exc_info = True )
13681381 return json .dumps (
@@ -1459,17 +1472,17 @@ def save_workflow(
14591472 WorkflowGraph (parsed )
14601473 except ValueError as exc :
14611474 return json .dumps ({"error" : f"Invalid workflow schema: { exc } " })
1462- with open ( workflow_file , "w" , encoding = "utf-8" ) as f :
1463- f . write ( payload )
1464- # Re-derive API endpoints so /info + /call track the saved graph
1465- # (outputs added / removed / renamed / retyped).
1466- if self . _api_endpoints is not None :
1467- try :
1468- self . _api_endpoints . sync ()
1469- except Exception :
1470- logger . error (
1471- "Workflow: endpoint sync after save failed" , exc_info = True
1472- )
1475+ with _save_lock :
1476+ with open ( workflow_file , "w" , encoding = "utf-8" ) as f :
1477+ f . write ( payload )
1478+ if self . _api_endpoints is not None :
1479+ try :
1480+ self . _api_endpoints . sync ()
1481+ except Exception :
1482+ logger . error (
1483+ "Workflow: endpoint sync after save failed" ,
1484+ exc_info = True ,
1485+ )
14731486 return "ok"
14741487 except Exception as e :
14751488 logger .error ("save_workflow failed: %s" , e , exc_info = True )
@@ -1491,7 +1504,6 @@ def save_workflow(
14911504 curated_modalities ,
14921505 curated_modality_tasks ,
14931506 get_dataset_schema ,
1494- call_fn ,
14951507 list_bound_fns ,
14961508 get_workflow_api ,
14971509 save_workflow ,
@@ -1520,6 +1532,51 @@ def _current_graph() -> WorkflowGraph | None:
15201532 server_functions = server_functions ,
15211533 )
15221534
1535+ def _wrap_bound_fn (fn : Callable ) -> Callable :
1536+ async def wrapper (
1537+ args_json : str ,
1538+ _request : Optional [Request ] = None ,
1539+ _token : Optional [OAuthToken ] = None ,
1540+ ) -> str :
1541+ args = json .loads (args_json )
1542+ if not isinstance (args , list ):
1543+ args = [args ]
1544+ args , * _ = _special_args (fn , args , _request , None , token = _token )
1545+ try :
1546+ result = (
1547+ await fn (* args )
1548+ if inspect .iscoroutinefunction (fn )
1549+ else await anyio .to_thread .run_sync (
1550+ lambda : fn (* args ), limiter = self .limiter
1551+ )
1552+ )
1553+ except Exception as e :
1554+ raise gr .Error (str (e )) from e
1555+ out = list (result ) if isinstance (result , (list , tuple )) else [result ]
1556+ return json .dumps (out )
1557+
1558+ return wrapper
1559+
1560+ if bound :
1561+ from gradio .workflow_api import _active_blocks
1562+
1563+ if len (bound ) != len ({_SANITIZE_RE .sub ("_" , n ) for n in bound }):
1564+ sanitized = [_SANITIZE_RE .sub ("_" , n ) for n in bound ]
1565+ dupes = {s for s in sanitized if sanitized .count (s ) > 1 }
1566+ raise ValueError (
1567+ f"gr.Workflow: bound function names produce duplicate endpoint "
1568+ f"names after sanitizing: { dupes } . Rename one."
1569+ )
1570+
1571+ with _active_blocks (self ):
1572+ for fn_name , fn in bound .items ():
1573+ sanitized_name = _SANITIZE_RE .sub ("_" , fn_name )
1574+ gr .api (
1575+ _wrap_bound_fn (fn ),
1576+ api_name = f"predict_fn_{ sanitized_name } " ,
1577+ concurrency_limit = "default" ,
1578+ api_visibility = "undocumented" ,
1579+ )
15231580 # Expose each subject (output) as a named API endpoint reusing /info +
15241581 # /call. The manager re-syncs on every save_workflow, so adding,
15251582 # removing, renaming, or retyping an output updates the live API.
0 commit comments