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

872 lines
35 KiB
Python

"""Server-side execution of `gr.Workflow` graphs, for exposing each workflow
subject (output node) as a regular Gradio API endpoint.
The canvas executes a workflow client-side (see
`js/workflowcanvas/workflow/workflow-executor.ts`). This module ports that
orchestration to Python so a workflow can be run headlessly — once per subject's
upstream sub-DAG — and wired into Gradio's normal `/info` + `/call` machinery.
Pure graph helpers (parsing, topo-sort, subgraph extraction, free-input
detection) have no Gradio/network dependencies and are unit-tested directly. The
executor calls back into the workflow server-functions (`call_space`,
`call_model`, `call_fn`, `fetch_dataset`) which are injected so they can be
mocked in tests.
"""
from __future__ import annotations
import contextlib
import inspect
import json
import logging
import re
from collections import deque
from collections.abc import Callable
from typing import Any, Optional
logger = logging.getLogger(__name__)
@contextlib.contextmanager
def _active_blocks(blocks):
"""Make `blocks` the active render target so components/events register into
it, without entering `Blocks.__exit__` — which would re-run
`attach_load_events` (duplicating the canvas's callable-`value` load event)
and recreate the running `App`. We only need the render-context primitives,
restored afterward."""
from gradio.context import Context
prev_root, prev_block = Context.root_block, Context.block
Context.root_block = blocks
Context.block = blocks
try:
yield
finally:
Context.root_block = prev_root
Context.block = prev_block
# Port types the canvas treats as files/media (mirrors MEDIA_PORT_TYPES in
# workflow-executor.ts). Values for these ports travel as `{path|url}` dicts
# rather than scalars.
MEDIA_PORT_TYPES = {"image", "audio", "video", "file", "gallery", "model3d"}
# ─────────────────────────────────────────────────────────────────────────────
# Graph model
# ─────────────────────────────────────────────────────────────────────────────
class WorkflowGraph:
"""Parsed, indexed view of a schema-v2 workflow dict.
Holds the three role collections (references / operators / subjects) plus a
flat id index and edge adjacency, so the executor and endpoint registration
don't re-scan lists repeatedly.
"""
def __init__(self, data: dict[str, Any]):
if not isinstance(data, dict):
raise ValueError("Workflow graph must be a JSON object")
self.raw = data
self.name: str = data.get("name") or "Workflow"
self.references: list[dict] = self._list_field(data, "references")
self.operators: list[dict] = self._list_field(data, "operators")
self.subjects: list[dict] = self._list_field(data, "subjects")
self.edges: list[dict] = self._list_field(data, "edges")
self.node_by_id: dict[str, dict] = {}
self.role_by_id: dict[str, str] = {}
for role, nodes in (
("reference", self.references),
("operator", self.operators),
("subject", self.subjects),
):
for i, n in enumerate(nodes):
if not isinstance(n, dict):
raise ValueError(f"Workflow {role} at index {i} must be an object")
node_id = n.get("id")
if not isinstance(node_id, str) or not node_id:
raise ValueError(f"Workflow {role} at index {i} is missing an id")
if node_id in self.node_by_id:
raise ValueError(f"Workflow contains duplicate node id: {node_id}")
self.node_by_id[node_id] = n
self.role_by_id[node_id] = role
for i, e in enumerate(self.edges):
if not isinstance(e, dict):
raise ValueError(f"Workflow edge at index {i} must be an object")
for key in ("from_node_id", "from_port_id", "to_node_id", "to_port_id"):
if not isinstance(e.get(key), str) or not e.get(key):
raise ValueError(f"Workflow edge at index {i} is missing {key}")
@staticmethod
def _list_field(data: dict[str, Any], key: str) -> list[dict]:
value = data.get(key) or []
if not isinstance(value, list):
raise ValueError(f"Workflow field '{key}' must be a list")
return list(value)
@classmethod
def from_json(cls, text: str | None) -> WorkflowGraph | None:
if not text:
return None
try:
data = json.loads(text)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(data, dict):
return None
if data.get("schema_version") != "2":
# Only the current schema is executable server-side; the frontend
# migrates v1→v2 on load, so saved files are v2.
return None
try:
return cls(data)
except ValueError:
return None
def has_incoming(self, node_id: str) -> bool:
return any(e.get("to_node_id") == node_id for e in self.edges)
def incoming_edge(self, node_id: str, port_id: str) -> dict | None:
for e in self.edges:
if e.get("to_node_id") == node_id and e.get("to_port_id") == port_id:
return e
return None
def topo_sort(node_ids: list[str], edges: list[dict]) -> list[str]:
"""Kahn's algorithm over the given node ids (edges referencing nodes outside
the set are ignored). Mirrors `topoSort` in workflow-graph.ts."""
ids = set(node_ids)
indeg = dict.fromkeys(node_ids, 0)
adj: dict[str, list[str]] = {nid: [] for nid in node_ids}
for e in edges:
a, b = e.get("from_node_id"), e.get("to_node_id")
if a in ids and b in ids:
indeg[b] += 1
adj[a].append(b)
queue = deque([nid for nid in node_ids if indeg[nid] == 0])
out: list[str] = []
while queue:
n = queue.popleft()
out.append(n)
for m in adj[n]:
indeg[m] -= 1
if indeg[m] == 0:
queue.append(m)
if len(out) != len(node_ids):
# A cycle leaves nodes with residual in-degree; surface it rather than
# silently dropping them (the canvas can't create cycles, but a
# hand-edited file could).
raise ValueError("Workflow contains a cycle and cannot be executed")
return out
def upstream_node_ids(graph: WorkflowGraph, target_id: str) -> set[str]:
"""All nodes transitively feeding `target_id` (inclusive). Mirrors
`buildUpstreamSubgraph` in workflow-graph.ts."""
include = {target_id}
queue = deque([target_id])
while queue:
cur = queue.popleft()
for e in graph.edges:
src = e.get("from_node_id")
# Skip edges whose source node doesn't exist: a hand-edited file can
# carry a dangling `from_node_id`. Including it would crash later in
# `_execute_node`'s raw `node_by_id[...]` lookup; leaving it out lets
# the downstream input resolve to None (and `_require` surface a
# clean "required but not provided" error if it mattered).
if (
e.get("to_node_id") == cur
and src in graph.node_by_id
and src not in include
):
include.add(src)
queue.append(src)
return include
def free_inputs(graph: WorkflowGraph, subgraph_ids: set[str]) -> list[dict]:
"""The reference nodes in the subgraph that the caller must supply (no
incoming edge → user-provided input). Returned in a stable order (graph
declaration order) so endpoint parameters are deterministic.
Each entry: `{"node": <reference dict>, "port": <output port dict>,
"type": <port type>, "label": <node label>}`.
"""
result = []
for ref in graph.references:
if ref["id"] not in subgraph_ids:
continue
if graph.has_incoming(ref["id"]):
continue # computed relay, not a free input
outputs = ref.get("outputs") or []
port = outputs[0] if outputs else None
port_type = (port or {}).get("type") or ref.get("asset_type") or "text"
result.append(
{
"node": ref,
"port": port,
"type": port_type,
"label": ref.get("label", ""),
}
)
return result
def subject_output_type(subject: dict) -> str:
inputs = subject.get("inputs") or []
if inputs:
return inputs[0].get("type") or subject.get("asset_type") or "text"
return subject.get("asset_type") or "text"
def subject_groups(graph: WorkflowGraph) -> list[list[dict]]:
"""Group subjects by weakly-connected component (edges undirected), mirroring
`countSubgraphs` in workflow-graph.ts. Each component is one independent
pipeline, so all of its outputs become a *single* API endpoint that returns
a tuple — matching how a Gradio Space with multiple outputs exposes one
endpoint, not one per output.
Groups are ordered by the declaration order of their first subject; subjects
within a group keep declaration order (this fixes the output tuple order)."""
parent: dict[str, str] = {nid: nid for nid in graph.node_by_id}
def find(x: str) -> str:
root = x
while parent[root] != root:
root = parent[root]
while parent[x] != root: # path compression
parent[x], x = root, parent[x]
return root
for e in graph.edges:
a, b = e.get("from_node_id"), e.get("to_node_id")
if a in parent and b in parent:
parent[find(a)] = find(b)
groups: dict[str, list[dict]] = {}
order: list[str] = []
for subject in graph.subjects:
root = find(subject["id"])
if root not in groups:
groups[root] = []
order.append(root)
groups[root].append(subject)
return [groups[root] for root in order]
def group_free_inputs(graph: WorkflowGraph, group: list[dict]) -> list[dict]:
"""The free inputs feeding a subject group: the union of each subject's
upstream subgraph, deduped and returned in graph declaration order (so a
reference shared by two outputs in the group becomes a single parameter)."""
sub_ids: set[str] = set()
for subject in group:
sub_ids |= upstream_node_ids(graph, subject["id"])
return free_inputs(graph, sub_ids)
# ─────────────────────────────────────────────────────────────────────────────
# Value marshalling (port of toGradioArg / fromGradioOutput / pick_response_item)
# ─────────────────────────────────────────────────────────────────────────────
def _to_arg(value: Any, port_type: str) -> Any:
"""Convert an endpoint input value into the shape the workflow server
functions expect. Media ports arrive as a local filepath (from the output
component) or a FileData-like dict; wrap a bare path as `{"path": ...}` so
`call_space` runs it through `handle_file`. Scalars pass through."""
if value is None:
return None
if port_type in MEDIA_PORT_TYPES:
if isinstance(value, dict):
return value # already {path|url|...}
if isinstance(value, str):
return {"path": value}
return value
return value
def _output_matches_port_type(item: Any, port_type: str) -> bool:
if item is None:
return False
if port_type in MEDIA_PORT_TYPES:
if isinstance(item, str):
return item.startswith(("http://", "https://", "blob:", "data:", "/"))
return isinstance(item, dict) and ("path" in item or "url" in item)
if port_type == "text":
return isinstance(item, str)
if port_type == "number":
return isinstance(item, (int, float)) and not isinstance(item, bool)
if port_type == "boolean":
return isinstance(item, bool)
if port_type == "json":
return isinstance(item, (dict, list))
return True
def _pick_response_item(
port: dict, port_index: int, output_data: list, total_ports: int
) -> Any:
"""Select which element of a multi-output response feeds a given output
port. Mirrors `pick_response_item` in workflow-executor.ts: honor an
explicit `output_index`, else position, else shape-match by port type."""
output_index = port.get("output_index")
if isinstance(output_index, int):
primary = output_data[output_index] if output_index < len(output_data) else None
elif total_ports == 1 and len(output_data) > 1:
primary = None
else:
primary = output_data[port_index] if port_index < len(output_data) else None
if primary is not None and _output_matches_port_type(primary, port.get("type", "")):
return primary
for item in output_data:
if _output_matches_port_type(item, port.get("type", "")):
return item
if primary is not None:
return primary
return output_data[0] if output_data else None
def _from_output(value: Any) -> Any:
"""Normalize a value coming out of the executor for the endpoint's output
component. File dicts → local path (preferred) or url; scalars pass
through. The internal `{path,url,is_file}` shape (from `call_space`) is
consumable by downstream operators as-is, so this only runs for the final
subject value handed to the output component."""
if isinstance(value, dict):
return value.get("path") or value.get("url") or value
return value
# ─────────────────────────────────────────────────────────────────────────────
# Executor
# ─────────────────────────────────────────────────────────────────────────────
class WorkflowExecutionError(Exception):
"""Raised when a node in the workflow fails; the message is suitable for
surfacing to an API caller."""
class WorkflowExecutor:
"""Runs a subject's upstream sub-DAG server-side.
`callers` maps operator kinds to the workflow server-functions, each with
the signature `(data: list, request, token) -> str` (a JSON string, either
a list of outputs or an `{"error": ...}` dict) — exactly the existing
`call_space` / `call_model` / `call_fn` / `fetch_dataset`.
"""
def __init__(self, graph: WorkflowGraph, callers: dict[str, Callable]):
self.graph = graph
self.callers = callers
def run(
self,
subject_id: str,
inputs: dict[str, Any],
request: Any = None,
token: Any = None,
) -> Any:
"""Execute the subgraph feeding `subject_id`. `inputs` maps free-input
reference node id → value. Returns the subject's output value."""
return self.run_many([subject_id], inputs, request, token)[0]
def run_many(
self,
subject_ids: list[str],
inputs: dict[str, Any],
request: Any = None,
token: Any = None,
) -> list[Any]:
"""Execute the combined subgraph feeding all `subject_ids` in a single
pass and return one output value per subject, in order. Nodes shared
between outputs (e.g. an operator with two outputs wired to two subjects)
run exactly once rather than once per subject."""
graph = self.graph
for sid in subject_ids:
if graph.role_by_id.get(sid) != "subject":
raise WorkflowExecutionError(f"No such workflow output: {sid}")
sub_ids: set[str] = set()
for sid in subject_ids:
sub_ids |= upstream_node_ids(graph, sid)
order = topo_sort(list(sub_ids), graph.edges)
data_map: dict[str, dict[str, Any]] = {}
self._request = request
self._token = token
for node_id in order:
self._execute_node(node_id, data_map, inputs)
outputs: list[Any] = []
for sid in subject_ids:
in_ports = graph.node_by_id[sid].get("inputs") or []
if not in_ports:
outputs.append(None)
continue
value = data_map.get(sid, {}).get(in_ports[0]["id"])
outputs.append(_from_output(value))
return outputs
# -- internals ----------------------------------------------------------
def _resolve_inputs(
self, node: dict, data_map: dict[str, dict[str, Any]]
) -> dict[str, Any]:
graph = self.graph
resolved: dict[str, Any] = {}
for port in node.get("inputs") or []:
edge = graph.incoming_edge(node["id"], port["id"])
if edge:
resolved[port["id"]] = data_map.get(edge["from_node_id"], {}).get(
edge["from_port_id"]
)
else:
data = node.get("data") or {}
resolved[port["id"]] = data.get(port["id"], port.get("default_value"))
return resolved
def _execute_node(
self,
node_id: str,
data_map: dict[str, dict[str, Any]],
inputs: dict[str, Any],
) -> None:
graph = self.graph
node = graph.node_by_id[node_id]
role = graph.role_by_id[node_id]
if role == "reference":
if graph.has_incoming(node_id):
self._relay(node, data_map) # computed reference (relay)
else:
self._seed_input(node, data_map, inputs) # free input
return
if role == "subject":
self._relay(node, data_map)
return
# operator
kind = node.get("kind")
if kind == "fn":
self._run_fn(node, data_map)
elif kind == "model":
self._run_model(node, data_map)
elif kind == "dataset":
self._run_dataset(node, data_map)
else: # "space" (default)
self._run_space(node, data_map)
def _seed_input(
self, node: dict, data_map: dict[str, dict[str, Any]], inputs: dict[str, Any]
) -> None:
value = inputs.get(node["id"])
bucket: dict[str, Any] = {}
for port in node.get("outputs") or []:
bucket[port["id"]] = _to_arg(value, port.get("type", "text"))
data_map[node["id"]] = bucket
def _relay(self, node: dict, data_map: dict[str, dict[str, Any]]) -> None:
resolved = self._resolve_inputs(node, data_map)
in_ports = node.get("inputs") or []
bucket: dict[str, Any] = {}
if in_ports:
value = resolved[in_ports[0]["id"]]
bucket[in_ports[0]["id"]] = value
for port in node.get("outputs") or []:
bucket[port["id"]] = value
data_map[node["id"]] = bucket
def _require(self, node: dict, resolved: dict[str, Any]) -> None:
for port in node.get("inputs") or []:
if port.get("required") and resolved.get(port["id"]) is None:
raise WorkflowExecutionError(
f'"{port.get("label", port["id"])}" is required by '
f'"{node.get("label", node["id"])}" but was not provided'
)
def _call(self, kind: str, data: list) -> list:
caller = self.callers.get(kind)
if caller is None:
raise WorkflowExecutionError(f"No executor available for '{kind}' nodes")
result_json = caller(data, self._request, self._token)
parsed = json.loads(result_json)
if isinstance(parsed, dict) and "error" in parsed:
msg = (
parsed.get("suggestion")
or parsed.get("error")
or "Workflow node failed"
)
raise WorkflowExecutionError(msg)
return parsed if isinstance(parsed, list) else [parsed]
def _map_outputs(
self, node: dict, output_data: list, data_map: dict[str, dict[str, Any]]
) -> None:
bucket: dict[str, Any] = {}
out_ports = node.get("outputs") or []
for i, port in enumerate(out_ports):
bucket[port["id"]] = _pick_response_item(
port, i, output_data, len(out_ports)
)
data_map[node["id"]] = bucket
def _run_space(self, node: dict, data_map: dict[str, dict[str, Any]]) -> None:
resolved = self._resolve_inputs(node, data_map)
self._require(node, resolved)
args = [resolved[p["id"]] for p in node.get("inputs") or []]
endpoint = node.get("endpoint") or "/predict"
output_data = self._call(
"space", [node.get("space_id"), endpoint, json.dumps(args)]
)
self._map_outputs(node, output_data, data_map)
def _run_model(self, node: dict, data_map: dict[str, dict[str, Any]]) -> None:
resolved = self._resolve_inputs(node, data_map)
self._require(node, resolved)
args = [resolved[p["id"]] for p in node.get("inputs") or []]
tag = node.get("pipeline_tag") or "text-generation"
provider = node.get("provider") or "auto"
output_data = self._call(
"model", [node.get("model_id"), tag, json.dumps(args), None, provider]
)
self._map_outputs(node, output_data, data_map)
def _run_fn(self, node: dict, data_map: dict[str, dict[str, Any]]) -> None:
resolved = self._resolve_inputs(node, data_map)
self._require(node, resolved)
args = [resolved[p["id"]] for p in node.get("inputs") or []]
output_data = self._call("fn", [node.get("fn"), json.dumps(args)])
self._map_outputs(node, output_data, data_map)
def _run_dataset(self, node: dict, data_map: dict[str, dict[str, Any]]) -> None:
resolved = self._resolve_inputs(node, data_map)
raw = resolved.get("row_index") or 0
try:
offset = max(0, int(raw))
except (TypeError, ValueError):
offset = 0
output_data = self._call(
"dataset",
[
node.get("dataset_id"),
node.get("dataset_config") or "default",
node.get("dataset_split") or "train",
str(offset),
"1",
],
)
# fetch_dataset returns {config, split, features, rows}; map the first
# row onto output ports by label (mirrors the dataset branch in the TS
# executor). Media cells arrive as `{src: url}` → normalize to a file
# value. Kept lenient — dataset operators are rarely terminal.
result = output_data[0] if output_data else {}
rows = result.get("rows") if isinstance(result, dict) else None
row = (rows[0] if rows else {}) if isinstance(rows, list) else {}
bucket: dict[str, Any] = {}
for port in node.get("outputs") or []:
value = row.get(port.get("label"))
if isinstance(value, dict) and "src" in value:
value = {"url": value["src"]}
bucket[port["id"]] = value
data_map[node["id"]] = bucket
# ─────────────────────────────────────────────────────────────────────────────
# Endpoint registration (B2: explicit, hidden components reusing /info + /call)
# ─────────────────────────────────────────────────────────────────────────────
def _slugify(label: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "_", (label or "").strip().lower()).strip("_")
return slug or "endpoint"
# Workflow port type → the Python type a gradio_client caller passes/receives,
# for display in the "View API" panel and generated snippets.
_PY_TYPE = {
"text": "str",
"number": "float",
"boolean": "bool",
"image": "filepath",
"audio": "filepath",
"video": "filepath",
"model3d": "filepath",
"3d": "filepath",
"file": "filepath",
"gallery": "list[filepath]",
"dataframe": "list[list]",
"json": "dict",
}
def _group_slug_iter(groups: list[list[dict]]):
"""Yield (group, api_name) with the dedup used at registration, so the
described endpoints line up with the ones actually registered. The slug is
derived from the group's first subject label."""
used: set[str] = set()
for group in groups:
api_name = _slugify(group[0].get("label", "output"))
while api_name in used:
api_name = f"{api_name}_"
used.add(api_name)
yield group, api_name
def describe_workflow_api(graph: WorkflowGraph) -> list[dict]:
"""Describe each subject endpoint for the frontend "View API" panel:
`api_name`, label, parameters (free inputs), and the return type. Mirrors
the schema that `register_workflow_endpoints` exposes via `/info`."""
endpoints = []
for group, api_name in _group_slug_iter(subject_groups(graph)):
frees = group_free_inputs(graph, group)
endpoints.append(
{
"api_name": "/" + api_name,
"label": group[0].get("label", "output"),
"parameters": [
{
"label": f["label"],
"parameter_name": f"in_{i}",
"type": f["type"],
"python_type": _PY_TYPE.get(f["type"], "str"),
}
for i, f in enumerate(frees)
],
"returns": [
{
"label": subject.get("label", "output"),
"type": subject_output_type(subject),
"python_type": _PY_TYPE.get(
subject_output_type(subject), "str"
),
}
for subject in group
],
}
)
return endpoints
def port_to_component(port_type: str, label: str):
"""Map a workflow port type to a Gradio component for the API schema. Real
components are used (not `gr.api` type hints) so multimodal I/O round-trips
via their proven `api_info` / `FileData` handling. Created unrendered; the
caller renders them inside a hidden container so they reach `/info`."""
import gradio as gr
label = label or port_type
if port_type == "image":
return gr.Image(label=label, type="filepath", render=False)
if port_type == "audio":
return gr.Audio(label=label, type="filepath", render=False)
if port_type == "video":
return gr.Video(label=label, render=False)
if port_type in ("model3d", "3d"):
return gr.Model3D(label=label, render=False)
if port_type == "file":
return gr.File(label=label, type="filepath", render=False)
if port_type == "number":
return gr.Number(label=label, render=False)
if port_type == "boolean":
return gr.Checkbox(label=label, render=False)
if port_type == "dataframe":
return gr.Dataframe(label=label, render=False)
if port_type == "gallery":
return gr.Gallery(label=label, render=False)
if port_type == "json":
return gr.JSON(label=label, render=False)
return gr.Textbox(label=label, render=False)
def _build_endpoint_fn(
get_graph: Callable[[], Optional[WorkflowGraph]],
subject_ids: list[str],
free_ids: list[str],
callers: dict[str, Callable],
):
"""Build the callable backing one subgraph endpoint. `subject_ids` are all
the outputs of the subgraph; with more than one the endpoint returns a tuple
(matching its multiple output components), otherwise a single value.
Execution re-reads the *current* graph via `get_graph()` so operator and
wiring edits take effect without a restart. The advertised signature
(positional input per free reference, plus `request`/`token`) is synthesized
so Gradio's `special_args` injects the request and OAuth token, letting the
executor reuse the workflow's token resolution for downstream calls.
"""
from gradio.oauth import OAuthToken
from gradio.route_utils import Request
def endpoint(*args):
*input_values, request, token = args
graph = get_graph()
if graph is None:
raise WorkflowExecutionError("Workflow graph is unavailable")
inputs = dict(zip(free_ids, input_values))
results = WorkflowExecutor(graph, callers).run_many(
subject_ids, inputs, request, token
)
return results[0] if len(results) == 1 else tuple(results)
params = [
inspect.Parameter(f"in_{i}", inspect.Parameter.POSITIONAL_OR_KEYWORD)
for i in range(len(free_ids))
]
params.append(
inspect.Parameter(
"request",
inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=None,
annotation=Optional[Request],
)
)
params.append(
inspect.Parameter(
"token",
inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=None,
annotation=Optional[OAuthToken],
)
)
endpoint.__signature__ = inspect.Signature(params) # type: ignore[attr-defined]
endpoint.__annotations__ = {
"request": Optional[Request],
"token": Optional[OAuthToken],
}
return endpoint
class WorkflowEndpointManager:
"""Owns the lifecycle of the per-subject API endpoints and keeps them in
sync with the workflow graph.
Each `sync()` tears down the previously-registered endpoints (their
components via `unrender()` and their event triggers from `blocks.fns`) and
rebuilds from the current graph, then refreshes the cached `/config` and
invalidates the `/info` cache so a running server reflects the change. This
lets the API track live edits: adding, removing, renaming, or retyping a
subject all re-derive the endpoint set on the next save.
"""
def __init__(
self,
blocks,
get_graph: Callable[[], Optional[WorkflowGraph]],
callers: dict[str, Callable],
):
self.blocks = blocks
self.get_graph = get_graph
self.callers = callers
self._blocks_created: list = []
self._fn_ids: list[int] = []
self.api_names: list[str] = []
def sync(self) -> list[str]:
"""Re-derive endpoints from the current graph. Safe to call repeatedly;
the first call registers, later calls reconcile."""
self._teardown()
graph = self.get_graph()
if graph is not None and graph.subjects:
self._register(graph)
self._refresh_app()
return list(self.api_names)
# -- internals ----------------------------------------------------------
def _teardown(self) -> None:
if self._blocks_created:
# unrender() needs the Blocks as the active context to remove blocks
# from its layout + id map.
with _active_blocks(self.blocks):
for block in self._blocks_created:
block.unrender()
for fn_id in self._fn_ids:
self.blocks.fns.pop(fn_id, None)
self._blocks_created = []
self._fn_ids = []
self.api_names = []
def _register(self, graph: WorkflowGraph) -> None:
import gradio as gr
before = set(self.blocks.fns.keys())
with _active_blocks(self.blocks), gr.Column(visible=False) as col:
self._blocks_created.append(col)
for group, api_name in _group_slug_iter(subject_groups(graph)):
frees = group_free_inputs(graph, group)
input_components = [
port_to_component(f["type"], f["label"]) for f in frees
]
for c in input_components:
c.render()
self._blocks_created.append(c)
output_components = []
for subject in group:
oc = port_to_component(
subject_output_type(subject), subject.get("label", "output")
)
oc.render()
self._blocks_created.append(oc)
output_components.append(oc)
fn = _build_endpoint_fn(
self.get_graph,
[s["id"] for s in group],
[f["node"]["id"] for f in frees],
self.callers,
)
trigger = gr.Button(visible=False)
self._blocks_created.append(trigger)
trigger.click(
fn,
inputs=input_components,
outputs=output_components
if len(output_components) > 1
else output_components[0],
api_name=api_name,
)
self.api_names.append(api_name)
# New event triggers added during registration (the order of insertion
# into the fns dict is the set difference from the pre-register snapshot).
self._fn_ids = [fid for fid in self.blocks.fns if fid not in before]
def _refresh_app(self) -> None:
"""After the endpoint set changes, refresh the cached config and (if
running) invalidate the `/info` cache.
`get_api_info` reads `self.config`, and `/config` serves a copy of it to
new page loads / `gradio_client` connections — so it must be regenerated
whenever the components or dependencies change. (The normal
`Blocks.__exit__` does this; our manual render context does not.)"""
try:
self.blocks.config = self.blocks.get_config_file()
except Exception:
logger.debug("Workflow: failed to refresh config", exc_info=True)
app = getattr(self.blocks, "server_app", None)
if app is not None:
app.api_info = None
app.all_app_info = None
def register_workflow_endpoints(
blocks,
get_graph: Callable[[], Optional[WorkflowGraph]],
callers: dict[str, Callable],
) -> WorkflowEndpointManager:
"""Create a `WorkflowEndpointManager` and register the initial endpoint set
from the current graph. Returns the manager so the caller can `.sync()` it
again whenever the graph is saved."""
manager = WorkflowEndpointManager(blocks, get_graph, callers)
manager.sync()
return manager