kestrel-workflows 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2024-2026 Kestrel AI, Inc.
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: kestrel-workflows
3
+ Version: 0.2.0
4
+ Summary: Python SDK for Kestrel — AI Agents for Cloud Operations
5
+ Author-email: Kestrel AI <support@usekestrel.ai>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://usekestrel.ai
8
+ Project-URL: Documentation, https://docs.usekestrel.ai/workflows/sdk
9
+ Project-URL: Repository, https://github.com/KestrelAI/kestrel-sdk
10
+ Keywords: kestrel,workflows,kubernetes,incident-response,automation,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: System :: Systems Administration
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: pydantic>=2.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
30
+ Requires-Dist: respx>=0.21; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # Kestrel SDK
34
+
35
+ Python SDK for [Kestrel](https://usekestrel.ai) — AI Agents for Cloud Operations.
36
+
37
+ Build, deploy, and manage workflows programmatically with a typed, fluent API.
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install kestrel-workflows
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```python
48
+ from kestrel import KestrelClient
49
+ from kestrel.workflows import Workflow, Trigger, Action
50
+
51
+ client = KestrelClient(api_key="kestrel_sk_...")
52
+
53
+ wf = (
54
+ Workflow("Pod Crash RCA + Jira")
55
+ .description("Run RCA on pod crash, create Jira ticket")
56
+ .trigger(
57
+ Trigger.k8s_pod_status()
58
+ .reasons("CrashLoopBackOff")
59
+ .namespace("production")
60
+ )
61
+ .cooldown(hours=24)
62
+ .then(Action.kestrel_trigger_rca().label("Run RCA"))
63
+ .then(Action.jira_create_ticket()
64
+ .project("KAN")
65
+ .title("{{incident.title}}")
66
+ .priority("High")
67
+ )
68
+ )
69
+
70
+ created = client.workflows.deploy(wf, activate=True)
71
+ print(f"Deployed: {created.id}")
72
+ ```
73
+
74
+ ## Async Support
75
+
76
+ ```python
77
+ from kestrel import AsyncKestrelClient
78
+
79
+ async with AsyncKestrelClient(api_key="kestrel_sk_...") as client:
80
+ workflows = await client.workflows.list()
81
+ execution = await client.workflows.test(workflows[0].id)
82
+ result = await client.executions.wait(execution.id)
83
+ print(f"Result: {result.status}")
84
+ ```
85
+
86
+ ## Authentication
87
+
88
+ Create an API key in the Kestrel platform under **Workflows > API Keys**.
89
+
90
+ ```python
91
+ # API key (recommended)
92
+ client = KestrelClient(api_key="kestrel_sk_...")
93
+
94
+ # From CLI login
95
+ client = KestrelClient.from_config()
96
+
97
+ # Async
98
+ client = AsyncKestrelClient(api_key="kestrel_sk_...")
99
+ ```
100
+
101
+ ## Documentation
102
+
103
+ Full SDK documentation: [docs.usekestrel.ai/workflows/sdk](https://docs.usekestrel.ai/workflows/sdk)
104
+
105
+ ## License
106
+
107
+ Apache 2.0
@@ -0,0 +1,75 @@
1
+ # Kestrel SDK
2
+
3
+ Python SDK for [Kestrel](https://usekestrel.ai) — AI Agents for Cloud Operations.
4
+
5
+ Build, deploy, and manage workflows programmatically with a typed, fluent API.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install kestrel-workflows
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from kestrel import KestrelClient
17
+ from kestrel.workflows import Workflow, Trigger, Action
18
+
19
+ client = KestrelClient(api_key="kestrel_sk_...")
20
+
21
+ wf = (
22
+ Workflow("Pod Crash RCA + Jira")
23
+ .description("Run RCA on pod crash, create Jira ticket")
24
+ .trigger(
25
+ Trigger.k8s_pod_status()
26
+ .reasons("CrashLoopBackOff")
27
+ .namespace("production")
28
+ )
29
+ .cooldown(hours=24)
30
+ .then(Action.kestrel_trigger_rca().label("Run RCA"))
31
+ .then(Action.jira_create_ticket()
32
+ .project("KAN")
33
+ .title("{{incident.title}}")
34
+ .priority("High")
35
+ )
36
+ )
37
+
38
+ created = client.workflows.deploy(wf, activate=True)
39
+ print(f"Deployed: {created.id}")
40
+ ```
41
+
42
+ ## Async Support
43
+
44
+ ```python
45
+ from kestrel import AsyncKestrelClient
46
+
47
+ async with AsyncKestrelClient(api_key="kestrel_sk_...") as client:
48
+ workflows = await client.workflows.list()
49
+ execution = await client.workflows.test(workflows[0].id)
50
+ result = await client.executions.wait(execution.id)
51
+ print(f"Result: {result.status}")
52
+ ```
53
+
54
+ ## Authentication
55
+
56
+ Create an API key in the Kestrel platform under **Workflows > API Keys**.
57
+
58
+ ```python
59
+ # API key (recommended)
60
+ client = KestrelClient(api_key="kestrel_sk_...")
61
+
62
+ # From CLI login
63
+ client = KestrelClient.from_config()
64
+
65
+ # Async
66
+ client = AsyncKestrelClient(api_key="kestrel_sk_...")
67
+ ```
68
+
69
+ ## Documentation
70
+
71
+ Full SDK documentation: [docs.usekestrel.ai/workflows/sdk](https://docs.usekestrel.ai/workflows/sdk)
72
+
73
+ ## License
74
+
75
+ Apache 2.0
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kestrel-workflows"
7
+ version = "0.2.0"
8
+ description = "Python SDK for Kestrel — AI Agents for Cloud Operations"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [
13
+ {name = "Kestrel AI", email = "support@usekestrel.ai"},
14
+ ]
15
+ keywords = ["kestrel", "workflows", "kubernetes", "incident-response", "automation", "sdk"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ "Topic :: System :: Systems Administration",
28
+ ]
29
+ dependencies = [
30
+ "httpx>=0.27",
31
+ "pydantic>=2.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://usekestrel.ai"
36
+ Documentation = "https://docs.usekestrel.ai/workflows/sdk"
37
+ Repository = "https://github.com/KestrelAI/kestrel-sdk"
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest>=8.0",
42
+ "pytest-asyncio>=0.23",
43
+ "respx>=0.21",
44
+ ]
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,39 @@
1
+ """Kestrel Python SDK — programmatic access to Kestrel Workflows."""
2
+
3
+ from .client import KestrelClient
4
+ from .async_client import AsyncKestrelClient
5
+ from .exceptions import AuthError, ConflictError, KestrelError, NotFoundError, ServerError
6
+ from .models import (
7
+ Approval,
8
+ Catalog,
9
+ Execution,
10
+ ExecutionList,
11
+ GenerateResult,
12
+ IntegrationStatus,
13
+ RequestResult,
14
+ SuggestedWorkflow,
15
+ Workflow,
16
+ WorkflowRequest,
17
+ WorkflowStats,
18
+ )
19
+
20
+ __all__ = [
21
+ "KestrelClient",
22
+ "AsyncKestrelClient",
23
+ "KestrelError",
24
+ "AuthError",
25
+ "NotFoundError",
26
+ "ConflictError",
27
+ "ServerError",
28
+ "Workflow",
29
+ "GenerateResult",
30
+ "WorkflowStats",
31
+ "Execution",
32
+ "ExecutionList",
33
+ "Approval",
34
+ "WorkflowRequest",
35
+ "RequestResult",
36
+ "SuggestedWorkflow",
37
+ "Catalog",
38
+ "IntegrationStatus",
39
+ ]
@@ -0,0 +1,266 @@
1
+ """Async Kestrel client — mirrors KestrelClient with async/await."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from .auth import Config, load_config
10
+ from .exceptions import AuthError, ConflictError, KestrelError, NotFoundError, ServerError
11
+ from .models import (
12
+ Approval,
13
+ Catalog,
14
+ Execution,
15
+ ExecutionList,
16
+ GenerateResult,
17
+ IntegrationStatus,
18
+ RequestResult,
19
+ SuggestedWorkflow,
20
+ Workflow,
21
+ WorkflowRequest,
22
+ WorkflowStats,
23
+ )
24
+
25
+
26
+ class _AsyncWorkflowsNamespace:
27
+ def __init__(self, client: AsyncKestrelClient):
28
+ self._c = client
29
+
30
+ async def list(self, *, status: str | None = None) -> list[Workflow]:
31
+ params = {}
32
+ if status:
33
+ params["status"] = status
34
+ data = await self._c._get("/api/workflows", params=params)
35
+ return [Workflow.model_validate(w) for w in data]
36
+
37
+ async def get(self, workflow_id: str) -> Workflow:
38
+ return Workflow.model_validate(await self._c._get(f"/api/workflows/{workflow_id}"))
39
+
40
+ async def create(self, *, name: str, description: str = "", definition: dict[str, Any] | None = None,
41
+ trigger_config: dict[str, Any] | None = None, nl_prompt: str = "",
42
+ alert_config: dict[str, Any] | None = None) -> Workflow:
43
+ body: dict[str, Any] = {"name": name, "description": description}
44
+ if definition is not None:
45
+ body["definition"] = definition
46
+ if trigger_config is not None:
47
+ body["trigger_config"] = trigger_config
48
+ if nl_prompt:
49
+ body["nl_prompt"] = nl_prompt
50
+ if alert_config is not None:
51
+ body["alert_config"] = alert_config
52
+ return Workflow.model_validate(await self._c._post("/api/workflows", json=body))
53
+
54
+ async def update(self, workflow_id: str, *, name: str | None = None, description: str | None = None,
55
+ definition: dict[str, Any] | None = None, trigger_config: dict[str, Any] | None = None,
56
+ nl_prompt: str | None = None) -> Workflow:
57
+ current = await self.get(workflow_id)
58
+ body: dict[str, Any] = {
59
+ "name": name if name is not None else current.name,
60
+ "description": description if description is not None else current.description,
61
+ "definition": definition if definition is not None else current.definition,
62
+ "trigger_config": trigger_config if trigger_config is not None else current.trigger_config,
63
+ "nl_prompt": nl_prompt if nl_prompt is not None else current.nl_prompt,
64
+ }
65
+ return Workflow.model_validate(await self._c._put(f"/api/workflows/{workflow_id}", json=body))
66
+
67
+ async def delete(self, workflow_id: str) -> None:
68
+ await self._c._delete(f"/api/workflows/{workflow_id}")
69
+
70
+ async def activate(self, workflow_id: str) -> None:
71
+ await self._c._post(f"/api/workflows/{workflow_id}/activate")
72
+
73
+ async def pause(self, workflow_id: str) -> None:
74
+ await self._c._post(f"/api/workflows/{workflow_id}/pause")
75
+
76
+ async def duplicate(self, workflow_id: str, *, name: str = "") -> Workflow:
77
+ return Workflow.model_validate(
78
+ await self._c._post(f"/api/workflows/{workflow_id}/duplicate", json={"name": name})
79
+ )
80
+
81
+ async def deploy(self, workflow: Any, *, activate: bool = False) -> Workflow:
82
+ """Create a workflow from a builder and optionally activate it."""
83
+ definition, trigger_config = workflow.build()
84
+ body: dict[str, Any] = {
85
+ "name": workflow._name,
86
+ "description": workflow._description,
87
+ "definition": definition,
88
+ "trigger_config": trigger_config,
89
+ }
90
+ if workflow._alert_config:
91
+ body["alert_config"] = workflow._alert_config
92
+ created = Workflow.model_validate(await self._c._post("/api/workflows", json=body))
93
+ if activate:
94
+ await self.activate(created.id)
95
+ created.status = "active"
96
+ return created
97
+
98
+ async def test(self, workflow_id: str) -> Execution:
99
+ return Execution.model_validate(await self._c._post(f"/api/workflows/{workflow_id}/test"))
100
+
101
+ async def generate(self, prompt: str) -> GenerateResult:
102
+ return GenerateResult.model_validate(
103
+ await self._c._post("/api/workflows/generate", json={"prompt": prompt})
104
+ )
105
+
106
+ async def stats(self) -> WorkflowStats:
107
+ return WorkflowStats.model_validate(await self._c._get("/api/workflows/stats"))
108
+
109
+ async def executions(self, workflow_id: str, *, page: int = 1, page_size: int = 20) -> ExecutionList:
110
+ return ExecutionList.model_validate(
111
+ await self._c._get(f"/api/workflows/{workflow_id}/executions",
112
+ params={"page": str(page), "page_size": str(page_size)})
113
+ )
114
+
115
+ async def catalog(self) -> Catalog:
116
+ return Catalog.model_validate(await self._c._get("/api/workflows/catalog"))
117
+
118
+ async def integrations(self) -> list[IntegrationStatus]:
119
+ data = await self._c._get("/api/workflows/integrations/status")
120
+ return [IntegrationStatus.model_validate(i) for i in data]
121
+
122
+ async def suggestions(self) -> list[SuggestedWorkflow]:
123
+ data = await self._c._get("/api/workflows/suggestions")
124
+ return [SuggestedWorkflow.model_validate(s) for s in data]
125
+
126
+
127
+ class _AsyncExecutionsNamespace:
128
+ def __init__(self, client: AsyncKestrelClient):
129
+ self._c = client
130
+
131
+ async def get(self, execution_id: str) -> Execution:
132
+ return Execution.model_validate(
133
+ await self._c._get(f"/api/workflow-executions/{execution_id}")
134
+ )
135
+
136
+ async def cancel(self, execution_id: str) -> None:
137
+ await self._c._post(f"/api/workflow-executions/{execution_id}/cancel")
138
+
139
+ async def wait(self, execution_id: str, *, poll_interval: float = 2.0, timeout: float = 300.0) -> Execution:
140
+ """Poll until execution completes, fails, or times out."""
141
+ import asyncio
142
+ import time
143
+ deadline = time.monotonic() + timeout
144
+ while time.monotonic() < deadline:
145
+ ex = await self.get(execution_id)
146
+ if ex.status in ("completed", "failed", "cancelled"):
147
+ return ex
148
+ await asyncio.sleep(poll_interval)
149
+ raise TimeoutError(f"Execution {execution_id} did not complete within {timeout}s")
150
+
151
+
152
+ class _AsyncApprovalsNamespace:
153
+ def __init__(self, client: AsyncKestrelClient):
154
+ self._c = client
155
+
156
+ async def list_pending(self) -> list[Approval]:
157
+ data = await self._c._get("/api/workflow-approvals/pending")
158
+ return [Approval.model_validate(a) for a in data]
159
+
160
+ async def approve(self, approval_id: str, *, justification: str | None = None) -> None:
161
+ body = {"justification": justification} if justification else None
162
+ await self._c._post(f"/api/workflow-approvals/{approval_id}/approve", json=body)
163
+
164
+ async def reject(self, approval_id: str) -> None:
165
+ await self._c._post(f"/api/workflow-approvals/{approval_id}/reject")
166
+
167
+
168
+ class _AsyncRequestsNamespace:
169
+ def __init__(self, client: AsyncKestrelClient):
170
+ self._c = client
171
+
172
+ async def list(self) -> list[WorkflowRequest]:
173
+ data = await self._c._get("/api/workflow-requests")
174
+ items = data.get("requests", []) if isinstance(data, dict) else data
175
+ results = [WorkflowRequest.model_validate(r) for r in items]
176
+ return [r for r in results if r.status in ("no_workflow", "approved", "rejected")]
177
+
178
+ async def approve(self, request_id: str) -> None:
179
+ await self._c._post(f"/api/workflow-requests/{request_id}/approve")
180
+
181
+ async def reject(self, request_id: str) -> None:
182
+ await self._c._post(f"/api/workflow-requests/{request_id}/reject")
183
+
184
+
185
+ class AsyncKestrelClient:
186
+ """Async entry point for the Kestrel Python SDK.
187
+
188
+ Usage::
189
+
190
+ async with AsyncKestrelClient(api_key="kestrel_sk_...") as client:
191
+ workflows = await client.workflows.list()
192
+ execution = await client.workflows.test(workflows[0].id)
193
+ result = await client.executions.wait(execution.id)
194
+ """
195
+
196
+ DEFAULT_SERVER = "https://platform.usekestrel.ai"
197
+
198
+ def __init__(
199
+ self,
200
+ server: str = DEFAULT_SERVER,
201
+ *,
202
+ api_key: str | None = None,
203
+ session_token: str | None = None,
204
+ timeout: float = 120.0,
205
+ ):
206
+ if api_key is None and session_token is None:
207
+ raise AuthError("Provide either api_key or session_token.")
208
+
209
+ server = server.rstrip("/")
210
+ headers: dict[str, str] = {"Content-Type": "application/json"}
211
+ if api_key is not None:
212
+ headers["Authorization"] = f"Bearer {api_key}"
213
+ else:
214
+ assert session_token is not None
215
+ headers["X-Session-Token"] = session_token
216
+
217
+ self._config = Config(server_url=server, session_token=session_token or "")
218
+ self._http = httpx.AsyncClient(base_url=server, headers=headers, timeout=timeout)
219
+ self.workflows = _AsyncWorkflowsNamespace(self)
220
+ self.executions = _AsyncExecutionsNamespace(self)
221
+ self.approvals = _AsyncApprovalsNamespace(self)
222
+ self.requests = _AsyncRequestsNamespace(self)
223
+
224
+ @classmethod
225
+ def from_config(cls) -> AsyncKestrelClient:
226
+ cfg = load_config()
227
+ if not cfg.is_logged_in:
228
+ raise AuthError("Not logged in. Run `kestrel login` or use AsyncKestrelClient(api_key=...).")
229
+ return cls(server=cfg.server_url, session_token=cfg.session_token)
230
+
231
+ async def close(self) -> None:
232
+ await self._http.aclose()
233
+
234
+ async def __aenter__(self) -> AsyncKestrelClient:
235
+ return self
236
+
237
+ async def __aexit__(self, *args: Any) -> None:
238
+ await self.close()
239
+
240
+ async def _get(self, path: str, *, params: dict[str, str] | None = None) -> Any:
241
+ return self._handle(await self._http.get(path, params=params))
242
+
243
+ async def _post(self, path: str, *, json: Any = None) -> Any:
244
+ return self._handle(await self._http.post(path, json=json))
245
+
246
+ async def _put(self, path: str, *, json: Any = None) -> Any:
247
+ return self._handle(await self._http.put(path, json=json))
248
+
249
+ async def _delete(self, path: str) -> Any:
250
+ return self._handle(await self._http.delete(path))
251
+
252
+ @staticmethod
253
+ def _handle(resp: httpx.Response) -> Any:
254
+ if resp.status_code == 401:
255
+ raise AuthError("Session expired or invalid. Re-authenticate.", status_code=401)
256
+ if resp.status_code == 404:
257
+ raise NotFoundError(resp.text[:200], status_code=404)
258
+ if resp.status_code == 409:
259
+ raise ConflictError(resp.text[:200], status_code=409)
260
+ if resp.status_code >= 500:
261
+ raise ServerError(resp.text[:200], status_code=resp.status_code)
262
+ if resp.status_code >= 400:
263
+ raise KestrelError(resp.text[:200], status_code=resp.status_code)
264
+ if not resp.text:
265
+ return None
266
+ return resp.json()
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class Config:
11
+ server_url: str = ""
12
+ session_token: str = ""
13
+ user_id: str = ""
14
+ email: str = ""
15
+
16
+ @property
17
+ def is_logged_in(self) -> bool:
18
+ return bool(self.server_url and self.session_token)
19
+
20
+
21
+ _CONFIG_DIR = Path.home() / ".kestrel"
22
+ _CONFIG_PATH = _CONFIG_DIR / "config.json"
23
+
24
+
25
+ def config_path() -> Path:
26
+ return _CONFIG_PATH
27
+
28
+
29
+ def load_config() -> Config:
30
+ if not _CONFIG_PATH.exists():
31
+ return Config()
32
+ raw = json.loads(_CONFIG_PATH.read_text())
33
+ return Config(
34
+ server_url=raw.get("server_url", ""),
35
+ session_token=raw.get("session_token", ""),
36
+ user_id=raw.get("user_id", ""),
37
+ email=raw.get("email", ""),
38
+ )
39
+
40
+
41
+ def save_config(cfg: Config) -> None:
42
+ _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
43
+ _CONFIG_PATH.write_text(
44
+ json.dumps(
45
+ {
46
+ "server_url": cfg.server_url,
47
+ "session_token": cfg.session_token,
48
+ "user_id": cfg.user_id,
49
+ "email": cfg.email,
50
+ },
51
+ indent=2,
52
+ )
53
+ )
54
+ os.chmod(_CONFIG_PATH, 0o600)