From 85dedc01a361a3b55ae917614acf04673d0f4242 Mon Sep 17 00:00:00 2001 From: deangoodmanson Date: Thu, 26 Mar 2026 17:58:13 -0500 Subject: [PATCH] add missing async methods to AsyncKruxiaFlow Ports start_workflow, get_workflow_output, and get_activity_output from KruxiaFlow (sync) to AsyncKruxiaFlow to restore API parity. Adds 17 tests covering success, 404, 401, 500, and network error cases for each. Co-Authored-By: Claude Sonnet 4.6 --- kruxiaflow/client.py | 101 ++++++++++++++++ tests/test_client.py | 274 +++++++++++++++++++++++++++++++++++++++++++ uv.lock | 15 ++- 3 files changed, 388 insertions(+), 2 deletions(-) diff --git a/kruxiaflow/client.py b/kruxiaflow/client.py index b7dd674..7f0b6e6 100644 --- a/kruxiaflow/client.py +++ b/kruxiaflow/client.py @@ -401,6 +401,107 @@ async def get_workflow(self, workflow_id: str) -> dict[str, Any]: except httpx.RequestError as e: raise KruxiaFlowError(f"Request failed: {e}") from e + async def start_workflow( + self, + workflow_name: str, + *, + inputs: dict[str, Any] | None = None, + version: str | None = None, + ) -> dict[str, Any]: + """Start a workflow execution by name. + + Args: + workflow_name: Name of the workflow to start + inputs: Input parameters for the workflow + version: Optional version (default: latest) + + Returns: + Response containing workflow instance ID and status + """ + body: dict[str, Any] = {"name": workflow_name} + if inputs: + body["inputs"] = inputs + if version: + body["version"] = version + + try: + response = await self._client.post("/api/v1/workflows/start", json=body) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + if e.response.status_code == 401: + raise AuthenticationError("Authentication failed") from e + if e.response.status_code == 404: + raise WorkflowNotFoundError( + f"Workflow '{workflow_name}' not found" + ) from e + raise KruxiaFlowError( + f"Failed to start workflow: {e.response.status_code} - {e.response.text}" + ) from e + except httpx.RequestError as e: + raise KruxiaFlowError(f"Request failed: {e}") from e + + async def get_workflow_output(self, workflow_id: str) -> dict[str, Any]: + """Get workflow output. + + Returns all activity outputs for a completed workflow. + + Args: + workflow_id: Unique workflow execution ID + + Returns: + Dictionary of activity outputs + """ + try: + response = await self._client.get( + f"/api/v1/workflows/{workflow_id}/output" + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + if e.response.status_code == 401: + raise AuthenticationError("Authentication failed") from e + if e.response.status_code == 404: + raise WorkflowNotFoundError( + f"Workflow '{workflow_id}' not found" + ) from e + raise KruxiaFlowError( + f"Failed to get workflow output: {e.response.status_code} - {e.response.text}" + ) from e + except httpx.RequestError as e: + raise KruxiaFlowError(f"Request failed: {e}") from e + + async def get_activity_output( + self, workflow_id: str, activity_key: str + ) -> dict[str, Any]: + """Get output for a specific activity. + + Args: + workflow_id: Unique workflow execution ID + activity_key: Activity key within the workflow + + Returns: + Activity output data + """ + try: + response = await self._client.get( + f"/api/v1/workflows/{workflow_id}/activities/{activity_key}/output" + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + if e.response.status_code == 401: + raise AuthenticationError("Authentication failed") from e + if e.response.status_code == 404: + raise WorkflowNotFoundError( + f"Workflow '{workflow_id}' or activity '{activity_key}' not found" + ) from e + raise KruxiaFlowError( + f"Failed to get activity output: {e.response.status_code} - {e.response.text}" + ) from e + except httpx.RequestError as e: + raise KruxiaFlowError(f"Request failed: {e}") from e + async def cancel_workflow(self, workflow_id: str) -> None: """Cancel a running workflow. diff --git a/tests/test_client.py b/tests/test_client.py index 0ac66ee..8264f46 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -992,3 +992,277 @@ async def test_cancel_workflow_network_error(self, httpx_mock: HTTPXMock): ) as client: with pytest.raises(KruxiaFlowError, match="Request failed"): await client.cancel_workflow("wf-123") + + +@pytest.mark.usefixtures("clean_env") +class TestAsyncKruxiaFlowStartWorkflow: + """Test AsyncKruxiaFlow start_workflow method.""" + + @pytest.mark.asyncio + async def test_start_workflow_success(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="POST", + url="http://localhost:8080/api/v1/workflows/start", + json={"instance_id": "inst-123", "status": "running"}, + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + result = await client.start_workflow("my_workflow") + + assert result["instance_id"] == "inst-123" + + @pytest.mark.asyncio + async def test_start_workflow_with_inputs(self, httpx_mock: HTTPXMock): + import json + + httpx_mock.add_response( + method="POST", + url="http://localhost:8080/api/v1/workflows/start", + json={"instance_id": "inst-456"}, + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + result = await client.start_workflow( + "my_workflow", + inputs={"param1": "value1"}, + version="2.0.0", + ) + + assert result["instance_id"] == "inst-456" + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert body["name"] == "my_workflow" + assert body["inputs"] == {"param1": "value1"} + assert body["version"] == "2.0.0" + + @pytest.mark.asyncio + async def test_start_workflow_not_found(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="POST", + url="http://localhost:8080/api/v1/workflows/start", + status_code=404, + text="Not found", + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(WorkflowNotFoundError, match="not found"): + await client.start_workflow("nonexistent") + + @pytest.mark.asyncio + async def test_start_workflow_auth_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="POST", + url="http://localhost:8080/api/v1/workflows/start", + status_code=401, + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(AuthenticationError): + await client.start_workflow("my_workflow") + + @pytest.mark.asyncio + async def test_start_workflow_server_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="POST", + url="http://localhost:8080/api/v1/workflows/start", + status_code=500, + text="Internal server error", + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(KruxiaFlowError, match="Failed to start workflow"): + await client.start_workflow("my_workflow") + + @pytest.mark.asyncio + async def test_start_workflow_network_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_exception( + httpx.ConnectError("Connection refused"), + url="http://localhost:8080/api/v1/workflows/start", + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(KruxiaFlowError, match="Request failed"): + await client.start_workflow("my_workflow") + + +@pytest.mark.usefixtures("clean_env") +class TestAsyncKruxiaFlowGetWorkflowOutput: + """Test AsyncKruxiaFlow get_workflow_output method.""" + + @pytest.mark.asyncio + async def test_get_workflow_output_success(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="http://localhost:8080/api/v1/workflows/wf-123/output", + json={"step1": {"result": "done"}, "step2": {"count": 42}}, + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + result = await client.get_workflow_output("wf-123") + + assert result["step1"]["result"] == "done" + assert result["step2"]["count"] == 42 + + @pytest.mark.asyncio + async def test_get_workflow_output_not_found(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="http://localhost:8080/api/v1/workflows/nonexistent/output", + status_code=404, + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(WorkflowNotFoundError): + await client.get_workflow_output("nonexistent") + + @pytest.mark.asyncio + async def test_get_workflow_output_auth_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="http://localhost:8080/api/v1/workflows/wf-123/output", + status_code=401, + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(AuthenticationError): + await client.get_workflow_output("wf-123") + + @pytest.mark.asyncio + async def test_get_workflow_output_server_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="http://localhost:8080/api/v1/workflows/wf-123/output", + status_code=500, + text="Internal server error", + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(KruxiaFlowError, match="Failed to get workflow output"): + await client.get_workflow_output("wf-123") + + @pytest.mark.asyncio + async def test_get_workflow_output_network_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_exception( + httpx.ConnectError("Connection refused"), + url="http://localhost:8080/api/v1/workflows/wf-123/output", + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(KruxiaFlowError, match="Request failed"): + await client.get_workflow_output("wf-123") + + +@pytest.mark.usefixtures("clean_env") +class TestAsyncKruxiaFlowGetActivityOutput: + """Test AsyncKruxiaFlow get_activity_output method.""" + + @pytest.mark.asyncio + async def test_get_activity_output_success(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="http://localhost:8080/api/v1/workflows/wf-123/activities/step1/output", + json={"result": "success", "data": [1, 2, 3]}, + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + result = await client.get_activity_output("wf-123", "step1") + + assert result["result"] == "success" + assert result["data"] == [1, 2, 3] + + @pytest.mark.asyncio + async def test_get_activity_output_not_found(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="http://localhost:8080/api/v1/workflows/wf-123/activities/missing/output", + status_code=404, + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(WorkflowNotFoundError, match="activity 'missing' not found"): + await client.get_activity_output("wf-123", "missing") + + @pytest.mark.asyncio + async def test_get_activity_output_auth_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="http://localhost:8080/api/v1/workflows/wf-123/activities/step1/output", + status_code=401, + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(AuthenticationError): + await client.get_activity_output("wf-123", "step1") + + @pytest.mark.asyncio + async def test_get_activity_output_server_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="http://localhost:8080/api/v1/workflows/wf-123/activities/step1/output", + status_code=500, + text="Internal server error", + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(KruxiaFlowError, match="Failed to get activity output"): + await client.get_activity_output("wf-123", "step1") + + @pytest.mark.asyncio + async def test_get_activity_output_network_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_exception( + httpx.ConnectError("Connection refused"), + url="http://localhost:8080/api/v1/workflows/wf-123/activities/step1/output", + ) + + async with AsyncKruxiaFlow( + api_url="http://localhost:8080", + api_token="token", + ) as client: + with pytest.raises(KruxiaFlowError, match="Request failed"): + await client.get_activity_output("wf-123", "step1") diff --git a/uv.lock b/uv.lock index a37b230..e79b2da 100644 --- a/uv.lock +++ b/uv.lock @@ -561,8 +561,8 @@ wheels = [ ] [[package]] -name = "kruxiaflow" -version = "0.1.0" +name = "kruxiaflow-python" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -2414,6 +2414,17 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/30/bfebdd8ec77db9a79775121789992d6b3b75ee5494971294d7b4b7c999bc/torch-2.10.0-2-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:2b980edd8d7c0a68c4e951ee1856334a43193f98730d97408fbd148c1a933313", size = 79411457, upload-time = "2026-02-10T21:44:59.189Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" }, + { url = "https://files.pythonhosted.org/packages/16/ee/efbd56687be60ef9af0c9c0ebe106964c07400eade5b0af8902a1d8cd58c/torch-2.10.0-3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a1ff626b884f8c4e897c4c33782bdacdff842a165fee79817b1dd549fdda1321", size = 915510070, upload-time = "2026-03-11T14:16:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/36/ab/7b562f1808d3f65414cd80a4f7d4bb00979d9355616c034c171249e1a303/torch-2.10.0-3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac5bdcbb074384c66fa160c15b1ead77839e3fe7ed117d667249afce0acabfac", size = 915518691, upload-time = "2026-03-11T14:15:43.147Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" }, { url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" }, { url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" }, { url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" },