voke 0.1.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.
- voke-0.1.0/.gitignore +49 -0
- voke-0.1.0/PKG-INFO +145 -0
- voke-0.1.0/README.md +127 -0
- voke-0.1.0/pyproject.toml +23 -0
- voke-0.1.0/src/voke/__init__.py +28 -0
- voke-0.1.0/src/voke/client.py +328 -0
- voke-0.1.0/src/voke/errors.py +64 -0
- voke-0.1.0/src/voke/models.py +119 -0
- voke-0.1.0/src/voke/tools/__init__.py +1 -0
- voke-0.1.0/src/voke/tools/crewai.py +58 -0
- voke-0.1.0/src/voke/tools/langchain.py +76 -0
- voke-0.1.0/tests/__init__.py +0 -0
- voke-0.1.0/tests/test_client.py +253 -0
- voke-0.1.0/tests/test_tools.py +115 -0
voke-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
*.egg
|
|
10
|
+
|
|
11
|
+
# Node
|
|
12
|
+
node_modules/
|
|
13
|
+
frontend/dist/
|
|
14
|
+
.cache/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
*~
|
|
22
|
+
|
|
23
|
+
# Environment
|
|
24
|
+
.env
|
|
25
|
+
.env.local
|
|
26
|
+
.env.*.local
|
|
27
|
+
|
|
28
|
+
# Secrets
|
|
29
|
+
*.pem
|
|
30
|
+
*.key
|
|
31
|
+
macvm-infra
|
|
32
|
+
id_rsa*
|
|
33
|
+
|
|
34
|
+
# OS
|
|
35
|
+
.DS_Store
|
|
36
|
+
Thumbs.db
|
|
37
|
+
|
|
38
|
+
# Test
|
|
39
|
+
.pytest_cache/
|
|
40
|
+
coverage/
|
|
41
|
+
.coverage
|
|
42
|
+
htmlcov/
|
|
43
|
+
|
|
44
|
+
# Build artifacts
|
|
45
|
+
cli/voke
|
|
46
|
+
action/dist/
|
|
47
|
+
mcp/dist/
|
|
48
|
+
frontend/
|
|
49
|
+
# Frontend dist is built in CI
|
voke-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: voke
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Voke — serverless macOS compute on Apple Silicon
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Provides-Extra: crewai
|
|
10
|
+
Requires-Dist: crewai-tools>=0.1; extra == 'crewai'
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
15
|
+
Provides-Extra: langchain
|
|
16
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# voke
|
|
20
|
+
|
|
21
|
+
Python SDK for [Voke](https://voke.run) -- serverless macOS compute on Apple Silicon. Run scripts on ephemeral macOS VMs with one function call.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install voke # core SDK
|
|
27
|
+
pip install voke[langchain] # + LangChain integration
|
|
28
|
+
pip install voke[crewai] # + CrewAI integration
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
### Sync
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from voke import Voke
|
|
37
|
+
|
|
38
|
+
client = Voke() # reads VOKE_API_KEY from env
|
|
39
|
+
result = client.jobs.create(script="uname -m && sw_vers").wait()
|
|
40
|
+
|
|
41
|
+
print(result.exit_code) # 0
|
|
42
|
+
print(result.stdout) # arm64\nProductName: macOS\n...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Async
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
from voke import AsyncVoke
|
|
50
|
+
|
|
51
|
+
async def main():
|
|
52
|
+
client = AsyncVoke()
|
|
53
|
+
job = await client.jobs.create(script="uname -m")
|
|
54
|
+
result = await job.async_wait()
|
|
55
|
+
print(result.stdout)
|
|
56
|
+
await client.close()
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## API reference
|
|
62
|
+
|
|
63
|
+
### `Voke(api_key=None, base_url="https://api.voke.run")`
|
|
64
|
+
|
|
65
|
+
Create a sync client. If `api_key` is not provided, reads `VOKE_API_KEY` from environment.
|
|
66
|
+
|
|
67
|
+
### `client.jobs.create(script, *, image="base", timeout=600, env=None, webhook_url=None) -> Job`
|
|
68
|
+
|
|
69
|
+
Submit a script. Returns a `Job` object immediately (status will be "queued").
|
|
70
|
+
|
|
71
|
+
### `client.jobs.get(job_id) -> Job`
|
|
72
|
+
|
|
73
|
+
Fetch a job by ID.
|
|
74
|
+
|
|
75
|
+
### `client.jobs.list(*, limit=20, offset=0, status=None) -> JobList`
|
|
76
|
+
|
|
77
|
+
List your jobs, newest first.
|
|
78
|
+
|
|
79
|
+
### `client.jobs.cancel(job_id) -> Job`
|
|
80
|
+
|
|
81
|
+
Cancel a queued job.
|
|
82
|
+
|
|
83
|
+
### `job.wait(poll_interval=1.5, timeout=None) -> Job`
|
|
84
|
+
|
|
85
|
+
Poll until the job reaches a terminal state (completed/failed/cancelled).
|
|
86
|
+
|
|
87
|
+
### `client.images() -> dict[str, ImageInfo]`
|
|
88
|
+
|
|
89
|
+
List available macOS images.
|
|
90
|
+
|
|
91
|
+
### `client.usage() -> UsageResponse`
|
|
92
|
+
|
|
93
|
+
Get current month's usage.
|
|
94
|
+
|
|
95
|
+
## Images
|
|
96
|
+
|
|
97
|
+
| Image | Description |
|
|
98
|
+
|-------|-------------|
|
|
99
|
+
| `base` | Clean macOS Sonoma 14.8 with dev tools |
|
|
100
|
+
| `xcode16` | base + Xcode 16, Swift 6, CocoaPods, Fastlane |
|
|
101
|
+
|
|
102
|
+
## Error handling
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from voke.errors import VokeError, AuthenticationError, RateLimitError
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
client.jobs.create(script="echo hello")
|
|
109
|
+
except AuthenticationError:
|
|
110
|
+
print("Bad API key")
|
|
111
|
+
except RateLimitError:
|
|
112
|
+
print("Too many requests")
|
|
113
|
+
except VokeError as e:
|
|
114
|
+
print(f"API error {e.status_code}: {e}")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## LangChain
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from voke.tools.langchain import VokeRunTool
|
|
121
|
+
|
|
122
|
+
tool = VokeRunTool() # reads VOKE_API_KEY from env
|
|
123
|
+
|
|
124
|
+
# Use with an agent
|
|
125
|
+
from langchain.agents import AgentExecutor
|
|
126
|
+
agent = AgentExecutor(tools=[tool], ...)
|
|
127
|
+
|
|
128
|
+
# Or call directly
|
|
129
|
+
result = tool.invoke({"script": "sw_vers", "image": "base"})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## CrewAI
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from voke.tools.crewai import VokeTool
|
|
136
|
+
from crewai import Agent
|
|
137
|
+
|
|
138
|
+
macos_tool = VokeTool()
|
|
139
|
+
|
|
140
|
+
agent = Agent(
|
|
141
|
+
role="macOS Engineer",
|
|
142
|
+
tools=[macos_tool],
|
|
143
|
+
...
|
|
144
|
+
)
|
|
145
|
+
```
|
voke-0.1.0/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# voke
|
|
2
|
+
|
|
3
|
+
Python SDK for [Voke](https://voke.run) -- serverless macOS compute on Apple Silicon. Run scripts on ephemeral macOS VMs with one function call.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install voke # core SDK
|
|
9
|
+
pip install voke[langchain] # + LangChain integration
|
|
10
|
+
pip install voke[crewai] # + CrewAI integration
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
### Sync
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from voke import Voke
|
|
19
|
+
|
|
20
|
+
client = Voke() # reads VOKE_API_KEY from env
|
|
21
|
+
result = client.jobs.create(script="uname -m && sw_vers").wait()
|
|
22
|
+
|
|
23
|
+
print(result.exit_code) # 0
|
|
24
|
+
print(result.stdout) # arm64\nProductName: macOS\n...
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Async
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import asyncio
|
|
31
|
+
from voke import AsyncVoke
|
|
32
|
+
|
|
33
|
+
async def main():
|
|
34
|
+
client = AsyncVoke()
|
|
35
|
+
job = await client.jobs.create(script="uname -m")
|
|
36
|
+
result = await job.async_wait()
|
|
37
|
+
print(result.stdout)
|
|
38
|
+
await client.close()
|
|
39
|
+
|
|
40
|
+
asyncio.run(main())
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## API reference
|
|
44
|
+
|
|
45
|
+
### `Voke(api_key=None, base_url="https://api.voke.run")`
|
|
46
|
+
|
|
47
|
+
Create a sync client. If `api_key` is not provided, reads `VOKE_API_KEY` from environment.
|
|
48
|
+
|
|
49
|
+
### `client.jobs.create(script, *, image="base", timeout=600, env=None, webhook_url=None) -> Job`
|
|
50
|
+
|
|
51
|
+
Submit a script. Returns a `Job` object immediately (status will be "queued").
|
|
52
|
+
|
|
53
|
+
### `client.jobs.get(job_id) -> Job`
|
|
54
|
+
|
|
55
|
+
Fetch a job by ID.
|
|
56
|
+
|
|
57
|
+
### `client.jobs.list(*, limit=20, offset=0, status=None) -> JobList`
|
|
58
|
+
|
|
59
|
+
List your jobs, newest first.
|
|
60
|
+
|
|
61
|
+
### `client.jobs.cancel(job_id) -> Job`
|
|
62
|
+
|
|
63
|
+
Cancel a queued job.
|
|
64
|
+
|
|
65
|
+
### `job.wait(poll_interval=1.5, timeout=None) -> Job`
|
|
66
|
+
|
|
67
|
+
Poll until the job reaches a terminal state (completed/failed/cancelled).
|
|
68
|
+
|
|
69
|
+
### `client.images() -> dict[str, ImageInfo]`
|
|
70
|
+
|
|
71
|
+
List available macOS images.
|
|
72
|
+
|
|
73
|
+
### `client.usage() -> UsageResponse`
|
|
74
|
+
|
|
75
|
+
Get current month's usage.
|
|
76
|
+
|
|
77
|
+
## Images
|
|
78
|
+
|
|
79
|
+
| Image | Description |
|
|
80
|
+
|-------|-------------|
|
|
81
|
+
| `base` | Clean macOS Sonoma 14.8 with dev tools |
|
|
82
|
+
| `xcode16` | base + Xcode 16, Swift 6, CocoaPods, Fastlane |
|
|
83
|
+
|
|
84
|
+
## Error handling
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from voke.errors import VokeError, AuthenticationError, RateLimitError
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
client.jobs.create(script="echo hello")
|
|
91
|
+
except AuthenticationError:
|
|
92
|
+
print("Bad API key")
|
|
93
|
+
except RateLimitError:
|
|
94
|
+
print("Too many requests")
|
|
95
|
+
except VokeError as e:
|
|
96
|
+
print(f"API error {e.status_code}: {e}")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## LangChain
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from voke.tools.langchain import VokeRunTool
|
|
103
|
+
|
|
104
|
+
tool = VokeRunTool() # reads VOKE_API_KEY from env
|
|
105
|
+
|
|
106
|
+
# Use with an agent
|
|
107
|
+
from langchain.agents import AgentExecutor
|
|
108
|
+
agent = AgentExecutor(tools=[tool], ...)
|
|
109
|
+
|
|
110
|
+
# Or call directly
|
|
111
|
+
result = tool.invoke({"script": "sw_vers", "image": "base"})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## CrewAI
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from voke.tools.crewai import VokeTool
|
|
118
|
+
from crewai import Agent
|
|
119
|
+
|
|
120
|
+
macos_tool = VokeTool()
|
|
121
|
+
|
|
122
|
+
agent = Agent(
|
|
123
|
+
role="macOS Engineer",
|
|
124
|
+
tools=[macos_tool],
|
|
125
|
+
...
|
|
126
|
+
)
|
|
127
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "voke"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for Voke — serverless macOS compute on Apple Silicon"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
"pydantic>=2.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
langchain = ["langchain-core>=0.3"]
|
|
19
|
+
crewai = ["crewai-tools>=0.1"]
|
|
20
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/voke"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Voke Python SDK — serverless macOS compute on Apple Silicon."""
|
|
2
|
+
|
|
3
|
+
from voke.client import Voke, AsyncVoke
|
|
4
|
+
from voke.models import Job, JobSummary, JobList, ImageInfo, UsageResponse
|
|
5
|
+
from voke.errors import (
|
|
6
|
+
VokeError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Voke",
|
|
15
|
+
"AsyncVoke",
|
|
16
|
+
"Job",
|
|
17
|
+
"JobSummary",
|
|
18
|
+
"JobList",
|
|
19
|
+
"ImageInfo",
|
|
20
|
+
"UsageResponse",
|
|
21
|
+
"VokeError",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"NotFoundError",
|
|
25
|
+
"ValidationError",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Voke API client — sync and async."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import asyncio
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from voke.errors import raise_for_status
|
|
13
|
+
from voke.models import Job, JobList, ImageInfo, UsageResponse
|
|
14
|
+
|
|
15
|
+
TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "timeout"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Sync client
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _SyncJobsResource:
|
|
24
|
+
"""jobs.create / .get / .list / .cancel"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, client: "Voke"):
|
|
27
|
+
self._c = client
|
|
28
|
+
|
|
29
|
+
def create(
|
|
30
|
+
self,
|
|
31
|
+
script: str,
|
|
32
|
+
*,
|
|
33
|
+
image: str = "base",
|
|
34
|
+
timeout: int = 600,
|
|
35
|
+
env: Optional[Dict[str, str]] = None,
|
|
36
|
+
labels: Optional[Dict[str, str]] = None,
|
|
37
|
+
webhook_url: Optional[str] = None,
|
|
38
|
+
) -> Job:
|
|
39
|
+
body: Dict[str, Any] = {"script": script, "image": image, "timeout": timeout}
|
|
40
|
+
if webhook_url:
|
|
41
|
+
body["webhook_url"] = webhook_url
|
|
42
|
+
if labels:
|
|
43
|
+
body["labels"] = labels
|
|
44
|
+
# Env vars: prepend exports to script
|
|
45
|
+
if env:
|
|
46
|
+
exports = "\n".join(f'export {k}={_shell_quote(v)}' for k, v in env.items())
|
|
47
|
+
body["script"] = f"{exports}\n{script}"
|
|
48
|
+
resp = self._c._request("POST", "/v1/jobs", json=body)
|
|
49
|
+
return Job.model_validate(resp)._bind(self._c)
|
|
50
|
+
|
|
51
|
+
def get(self, job_id: str) -> Job:
|
|
52
|
+
resp = self._c._request("GET", f"/v1/jobs/{job_id}")
|
|
53
|
+
return Job.model_validate(resp)._bind(self._c)
|
|
54
|
+
|
|
55
|
+
def list(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
limit: int = 20,
|
|
59
|
+
offset: int = 0,
|
|
60
|
+
status: Optional[str] = None,
|
|
61
|
+
) -> JobList:
|
|
62
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
63
|
+
if status:
|
|
64
|
+
params["status"] = status
|
|
65
|
+
resp = self._c._request("GET", "/v1/jobs", params=params)
|
|
66
|
+
return JobList.model_validate(resp)
|
|
67
|
+
|
|
68
|
+
def cancel(self, job_id: str) -> Job:
|
|
69
|
+
resp = self._c._request("DELETE", f"/v1/jobs/{job_id}")
|
|
70
|
+
return Job.model_validate(resp)._bind(self._c)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Voke:
|
|
74
|
+
"""Synchronous Voke API client."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
api_key: Optional[str] = None,
|
|
79
|
+
base_url: str = "https://api.voke.run",
|
|
80
|
+
*,
|
|
81
|
+
http_client: Optional[httpx.Client] = None,
|
|
82
|
+
):
|
|
83
|
+
self.api_key = api_key or os.environ.get("VOKE_API_KEY", "")
|
|
84
|
+
self.base_url = base_url.rstrip("/")
|
|
85
|
+
self._http = http_client or httpx.Client(timeout=30.0)
|
|
86
|
+
self.jobs = _SyncJobsResource(self)
|
|
87
|
+
|
|
88
|
+
def _headers(self) -> Dict[str, str]:
|
|
89
|
+
return {
|
|
90
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
91
|
+
"Accept": "application/json",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def _request(
|
|
95
|
+
self, method: str, path: str, *, json: Any = None, params: Any = None
|
|
96
|
+
) -> Any:
|
|
97
|
+
max_attempts = 3
|
|
98
|
+
for attempt in range(max_attempts):
|
|
99
|
+
try:
|
|
100
|
+
resp = self._http.request(
|
|
101
|
+
method,
|
|
102
|
+
f"{self.base_url}{path}",
|
|
103
|
+
headers=self._headers(),
|
|
104
|
+
json=json,
|
|
105
|
+
params=params,
|
|
106
|
+
)
|
|
107
|
+
except httpx.TransportError:
|
|
108
|
+
if attempt >= max_attempts - 1:
|
|
109
|
+
raise
|
|
110
|
+
time.sleep(0.5 * 2**attempt)
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if resp.status_code == 429:
|
|
114
|
+
if attempt >= max_attempts - 1:
|
|
115
|
+
body = resp.json() if resp.content else {}
|
|
116
|
+
raise_for_status(resp.status_code, body)
|
|
117
|
+
retry_after = resp.headers.get("Retry-After")
|
|
118
|
+
wait = float(retry_after) if retry_after else 2.0
|
|
119
|
+
time.sleep(wait)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if resp.status_code >= 500:
|
|
123
|
+
if attempt >= max_attempts - 1:
|
|
124
|
+
body = resp.json() if resp.content else {}
|
|
125
|
+
raise_for_status(resp.status_code, body)
|
|
126
|
+
time.sleep(0.5 * 2**attempt)
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
body = resp.json() if resp.content else {}
|
|
130
|
+
raise_for_status(resp.status_code, body)
|
|
131
|
+
return body
|
|
132
|
+
|
|
133
|
+
# Unreachable, but satisfies type checker
|
|
134
|
+
raise RuntimeError("Exhausted retries")
|
|
135
|
+
|
|
136
|
+
def _poll_job(
|
|
137
|
+
self,
|
|
138
|
+
job_id: str,
|
|
139
|
+
*,
|
|
140
|
+
poll_interval: float = 1.5,
|
|
141
|
+
timeout: Optional[float] = None,
|
|
142
|
+
) -> Job:
|
|
143
|
+
deadline = time.time() + timeout if timeout else None
|
|
144
|
+
while True:
|
|
145
|
+
job = self.jobs.get(job_id)
|
|
146
|
+
if job.is_terminal:
|
|
147
|
+
return job
|
|
148
|
+
if deadline and time.time() >= deadline:
|
|
149
|
+
raise TimeoutError(f"Job {job_id} did not finish within {timeout}s")
|
|
150
|
+
time.sleep(poll_interval)
|
|
151
|
+
|
|
152
|
+
def images(self) -> Dict[str, ImageInfo]:
|
|
153
|
+
resp = self._request("GET", "/v1/images")
|
|
154
|
+
return {k: ImageInfo.model_validate(v) for k, v in resp.items()}
|
|
155
|
+
|
|
156
|
+
def usage(self) -> UsageResponse:
|
|
157
|
+
resp = self._request("GET", "/v1/auth/usage")
|
|
158
|
+
return UsageResponse.model_validate(resp)
|
|
159
|
+
|
|
160
|
+
def close(self) -> None:
|
|
161
|
+
self._http.close()
|
|
162
|
+
|
|
163
|
+
def __enter__(self) -> "Voke":
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def __exit__(self, *args: Any) -> None:
|
|
167
|
+
self.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Async client
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _AsyncJobsResource:
|
|
176
|
+
"""Async jobs.create / .get / .list / .cancel"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, client: "AsyncVoke"):
|
|
179
|
+
self._c = client
|
|
180
|
+
|
|
181
|
+
async def create(
|
|
182
|
+
self,
|
|
183
|
+
script: str,
|
|
184
|
+
*,
|
|
185
|
+
image: str = "base",
|
|
186
|
+
timeout: int = 600,
|
|
187
|
+
env: Optional[Dict[str, str]] = None,
|
|
188
|
+
labels: Optional[Dict[str, str]] = None,
|
|
189
|
+
webhook_url: Optional[str] = None,
|
|
190
|
+
) -> Job:
|
|
191
|
+
body: Dict[str, Any] = {"script": script, "image": image, "timeout": timeout}
|
|
192
|
+
if webhook_url:
|
|
193
|
+
body["webhook_url"] = webhook_url
|
|
194
|
+
if labels:
|
|
195
|
+
body["labels"] = labels
|
|
196
|
+
if env:
|
|
197
|
+
exports = "\n".join(f'export {k}={_shell_quote(v)}' for k, v in env.items())
|
|
198
|
+
body["script"] = f"{exports}\n{script}"
|
|
199
|
+
resp = await self._c._request("POST", "/v1/jobs", json=body)
|
|
200
|
+
return Job.model_validate(resp)._bind(self._c)
|
|
201
|
+
|
|
202
|
+
async def get(self, job_id: str) -> Job:
|
|
203
|
+
resp = await self._c._request("GET", f"/v1/jobs/{job_id}")
|
|
204
|
+
return Job.model_validate(resp)._bind(self._c)
|
|
205
|
+
|
|
206
|
+
async def list(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
limit: int = 20,
|
|
210
|
+
offset: int = 0,
|
|
211
|
+
status: Optional[str] = None,
|
|
212
|
+
) -> JobList:
|
|
213
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
214
|
+
if status:
|
|
215
|
+
params["status"] = status
|
|
216
|
+
resp = await self._c._request("GET", "/v1/jobs", params=params)
|
|
217
|
+
return JobList.model_validate(resp)
|
|
218
|
+
|
|
219
|
+
async def cancel(self, job_id: str) -> Job:
|
|
220
|
+
resp = await self._c._request("DELETE", f"/v1/jobs/{job_id}")
|
|
221
|
+
return Job.model_validate(resp)._bind(self._c)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class AsyncVoke:
|
|
225
|
+
"""Asynchronous Voke API client."""
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
api_key: Optional[str] = None,
|
|
230
|
+
base_url: str = "https://api.voke.run",
|
|
231
|
+
*,
|
|
232
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
233
|
+
):
|
|
234
|
+
self.api_key = api_key or os.environ.get("VOKE_API_KEY", "")
|
|
235
|
+
self.base_url = base_url.rstrip("/")
|
|
236
|
+
self._http = http_client or httpx.AsyncClient(timeout=30.0)
|
|
237
|
+
self.jobs = _AsyncJobsResource(self)
|
|
238
|
+
|
|
239
|
+
def _headers(self) -> Dict[str, str]:
|
|
240
|
+
return {
|
|
241
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
242
|
+
"Accept": "application/json",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async def _request(
|
|
246
|
+
self, method: str, path: str, *, json: Any = None, params: Any = None
|
|
247
|
+
) -> Any:
|
|
248
|
+
max_attempts = 3
|
|
249
|
+
for attempt in range(max_attempts):
|
|
250
|
+
try:
|
|
251
|
+
resp = await self._http.request(
|
|
252
|
+
method,
|
|
253
|
+
f"{self.base_url}{path}",
|
|
254
|
+
headers=self._headers(),
|
|
255
|
+
json=json,
|
|
256
|
+
params=params,
|
|
257
|
+
)
|
|
258
|
+
except httpx.TransportError:
|
|
259
|
+
if attempt >= max_attempts - 1:
|
|
260
|
+
raise
|
|
261
|
+
await asyncio.sleep(0.5 * 2**attempt)
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
if resp.status_code == 429:
|
|
265
|
+
if attempt >= max_attempts - 1:
|
|
266
|
+
body = resp.json() if resp.content else {}
|
|
267
|
+
raise_for_status(resp.status_code, body)
|
|
268
|
+
retry_after = resp.headers.get("Retry-After")
|
|
269
|
+
wait = float(retry_after) if retry_after else 2.0
|
|
270
|
+
await asyncio.sleep(wait)
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
if resp.status_code >= 500:
|
|
274
|
+
if attempt >= max_attempts - 1:
|
|
275
|
+
body = resp.json() if resp.content else {}
|
|
276
|
+
raise_for_status(resp.status_code, body)
|
|
277
|
+
await asyncio.sleep(0.5 * 2**attempt)
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
body = resp.json() if resp.content else {}
|
|
281
|
+
raise_for_status(resp.status_code, body)
|
|
282
|
+
return body
|
|
283
|
+
|
|
284
|
+
# Unreachable, but satisfies type checker
|
|
285
|
+
raise RuntimeError("Exhausted retries")
|
|
286
|
+
|
|
287
|
+
async def _poll_job(
|
|
288
|
+
self,
|
|
289
|
+
job_id: str,
|
|
290
|
+
*,
|
|
291
|
+
poll_interval: float = 1.5,
|
|
292
|
+
timeout: Optional[float] = None,
|
|
293
|
+
) -> Job:
|
|
294
|
+
deadline = time.time() + timeout if timeout else None
|
|
295
|
+
while True:
|
|
296
|
+
job = await self.jobs.get(job_id)
|
|
297
|
+
if job.is_terminal:
|
|
298
|
+
return job
|
|
299
|
+
if deadline and time.time() >= deadline:
|
|
300
|
+
raise TimeoutError(f"Job {job_id} did not finish within {timeout}s")
|
|
301
|
+
await asyncio.sleep(poll_interval)
|
|
302
|
+
|
|
303
|
+
async def images(self) -> Dict[str, ImageInfo]:
|
|
304
|
+
resp = await self._request("GET", "/v1/images")
|
|
305
|
+
return {k: ImageInfo.model_validate(v) for k, v in resp.items()}
|
|
306
|
+
|
|
307
|
+
async def usage(self) -> UsageResponse:
|
|
308
|
+
resp = await self._request("GET", "/v1/auth/usage")
|
|
309
|
+
return UsageResponse.model_validate(resp)
|
|
310
|
+
|
|
311
|
+
async def close(self) -> None:
|
|
312
|
+
await self._http.aclose()
|
|
313
|
+
|
|
314
|
+
async def __aenter__(self) -> "AsyncVoke":
|
|
315
|
+
return self
|
|
316
|
+
|
|
317
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
318
|
+
await self.close()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Helpers
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _shell_quote(s: str) -> str:
|
|
327
|
+
"""Simple shell quoting for env var values."""
|
|
328
|
+
return "'" + s.replace("'", "'\\''") + "'"
|