feat: deliver Python workflow client

This commit is contained in:
lda
2026-08-31 02:49:07 +07:00 Verified
parent d53b96fd7c
commit 5315d4b66e
18 changed files with 717 additions and 19 deletions
+15
View File
@@ -54,3 +54,18 @@ def test_durable_workflow_api_returns_workflow_api_with_same_context(tmp_path) -
assert isinstance(api, WorkflowApi)
assert api.context is context
def test_durable_workflow_api_can_opt_out_of_draft_store(tmp_path) -> None:
stores = file_workflow_stores(tmp_path / "workflow_stores")
service = WfMcpService(
store=FileStore(tmp_path / "mcp"),
artifact_store=stores.artifact_store,
draft_workspace_store=None,
run_store=stores.run_store,
)
context = context_from_service(service)
api = durable_workflow_api(context, drafts=False)
assert api.drafts_enabled is False
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import httpx
import pytest
from wf_authoring import input_from, input_value, output_to, state_path
from wf_client import App, ArtifactRef
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
@pytest.mark.asyncio
async def test_http_app_calls_authors_saves_deploys_and_runs(tmp_path) -> None:
"""Prove the public client lifecycle against the real JSON-RPC ASGI app."""
server = build_local_static_workflow_server(tmp_path / "store")
rpc_app = create_rpc_app(server)
transport = httpx.ASGITransport(app=rpc_app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://test",
) as http_client:
app = App._from_port(
RpcWorkflowApiClient(
url="http://test/rpc",
http_client=http_client,
)
)
constant = await app.capability("wf.std.constant")
graph = app.new_workflow(
"http_client_proof",
input_schema={"type": "object", "properties": {}},
state_schema={
"type": "object",
"properties": {"value": {"type": "string"}},
},
output_schema={
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
)
step = graph.use(
constant,
id="constant",
input=[input_value("value", "hello")],
output=[output_to("value", state_path("value"))],
)
end = graph.end("ok", id="end_ok")
graph.set_entry_point(step)
graph.connect(step, "ok", end)
graph.set_output([input_from(state_path("value"), "value")])
validation = await graph.validate()
artifact = await graph.save(version=1, title="HTTP client proof")
run = await artifact.run({})
assert validation.ok is True
assert artifact.ref == ArtifactRef("http_client_proof", 1)
assert run.status == "completed"
assert run.output == {"value": "hello"}
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
from typing import Any, cast
from wf_client.capabilities import CapabilityResult, RemoteCapability
from wf_client.deployments import Deployment, DeploymentValidation
from wf_client.protocols import WorkflowClientPort
from wf_client.runs import Run, TracePage
from wf_client.workflows import (
ArtifactRef,
WorkflowDiagnostic,
WorkflowValidation,
)
from wf_core import ValidationReport
from wf_platform import CapabilityRef
class _Port:
"""A port that records accidental representation-time remote operations."""
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
def __getattr__(self, name: str) -> Any:
async def operation(**params: Any) -> object:
self.calls.append((name, params))
return {}
return operation
def _port() -> _Port:
return _Port()
def test_capability_html_repr_is_bounded_and_does_not_call_port() -> None:
port = _port()
remote = RemoteCapability(
_port=cast(WorkflowClientPort, port),
ref=CapabilityRef.parse("app.default.search"),
qualified_name="app.default.search",
description="Search things <carefully>",
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
output_schema={"type": "object", "properties": {"items": {"type": "array"}}},
outcomes=("ok",),
is_async=False,
)
rendered = remote._repr_html_()
assert "app.default.search" in rendered
assert "input schema" in rendered.lower()
assert "&lt;carefully&gt;" in rendered
assert port.calls == []
def test_rich_representations_bound_large_values_and_redact_secret_like_fields() -> (
None
):
port = _port()
result = CapabilityResult(
outcome="ok",
output={"token": "do-not-show", "items": ["x" * 400] * 20},
diagnostics=(),
)
run = Run(
_port=cast(WorkflowClientPort, port),
run_id="run-1",
deployment_id="deployment-1",
status="completed",
outcome="ok",
output=result.output,
interrupt=None,
diagnostics=(),
trace_count=1000,
)
rendered = repr(run)
html = run._repr_html_()
assert len(rendered) <= 1_201
assert len(html) <= 2_500
assert "do-not-show" not in rendered
assert "do-not-show" not in html
assert "1000 frames" in rendered
assert port.calls == []
def test_all_rich_objects_render_without_port_access() -> None:
port = cast(WorkflowClientPort, _port())
diagnostic = WorkflowDiagnostic("error", "bad", "state.x", "broken")
local = ValidationReport()
objects = [
ArtifactRef("artifact", 1),
diagnostic,
WorkflowValidation(local, "valid", (diagnostic,)),
CapabilityResult("ok", {"value": 1}, ()),
DeploymentValidation("deployment", "artifact", 1, "runnable", ()),
TracePage(0, 25, (), False, 0),
]
for value in objects:
assert repr(value)
assert value._repr_html_()
assert repr(
Deployment.from_payload(
port,
{
"id": "deployment",
"artifact_id": "artifact",
"artifact_version": 1,
"bindings": [],
"drift_policy": "block",
},
)
)
assert port.calls == []
+11
View File
@@ -32,6 +32,17 @@ async def _rpc(
return response.json()
def test_rpc_app_can_omit_draft_methods(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
assert server.api.drafts_enabled is True
app = create_rpc_app(server, drafts=False)
methods = {method["name"] for method in app.get_openrpc()["methods"]}
assert "workflow.capabilities.list" in methods
assert "workflow.draft_workspaces.list" not in methods
def _rpc_constant_draft() -> dict[str, Any]:
"""Return the canonical keyed draft shared by stateless RPC tests."""
return {