feat: deliver Python workflow client
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
@@ -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 "<carefully>" 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 == []
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user