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 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("'", "'\\''") + "'"