treinta-previews 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.
- treinta_previews-0.1.0/.gitignore +9 -0
- treinta_previews-0.1.0/CHANGELOG.md +23 -0
- treinta_previews-0.1.0/LICENSE +21 -0
- treinta_previews-0.1.0/PKG-INFO +241 -0
- treinta_previews-0.1.0/README.md +204 -0
- treinta_previews-0.1.0/pyproject.toml +80 -0
- treinta_previews-0.1.0/scripts/smoke.py +79 -0
- treinta_previews-0.1.0/src/previews/__init__.py +88 -0
- treinta_previews-0.1.0/src/previews/_case.py +48 -0
- treinta_previews-0.1.0/src/previews/_config.py +45 -0
- treinta_previews-0.1.0/src/previews/_http.py +62 -0
- treinta_previews-0.1.0/src/previews/_version.py +3 -0
- treinta_previews-0.1.0/src/previews/archive.py +271 -0
- treinta_previews-0.1.0/src/previews/async_client.py +122 -0
- treinta_previews-0.1.0/src/previews/client.py +136 -0
- treinta_previews-0.1.0/src/previews/errors.py +36 -0
- treinta_previews-0.1.0/src/previews/models.py +351 -0
- treinta_previews-0.1.0/src/previews/proxy/__init__.py +27 -0
- treinta_previews-0.1.0/src/previews/proxy/core.py +122 -0
- treinta_previews-0.1.0/src/previews/proxy/fastapi.py +139 -0
- treinta_previews-0.1.0/src/previews/proxy/flask.py +129 -0
- treinta_previews-0.1.0/src/previews/py.typed +0 -0
- treinta_previews-0.1.0/src/previews/resources/__init__.py +70 -0
- treinta_previews-0.1.0/src/previews/resources/accounts.py +29 -0
- treinta_previews-0.1.0/src/previews/resources/detect.py +64 -0
- treinta_previews-0.1.0/src/previews/resources/drives.py +64 -0
- treinta_previews-0.1.0/src/previews/resources/integrations.py +58 -0
- treinta_previews-0.1.0/src/previews/resources/snapshots.py +86 -0
- treinta_previews-0.1.0/src/previews/resources/vms.py +335 -0
- treinta_previews-0.1.0/src/previews/sse.py +205 -0
- treinta_previews-0.1.0/tests/conftest.py +43 -0
- treinta_previews-0.1.0/tests/test_archive.py +116 -0
- treinta_previews-0.1.0/tests/test_case.py +67 -0
- treinta_previews-0.1.0/tests/test_live.py +35 -0
- treinta_previews-0.1.0/tests/test_models.py +254 -0
- treinta_previews-0.1.0/tests/test_proxy.py +147 -0
- treinta_previews-0.1.0/tests/test_sse.py +115 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `treinta-previews` are documented here. This project
|
|
4
|
+
adheres to [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-07-01
|
|
7
|
+
|
|
8
|
+
Initial release. 1:1 parity with the Node SDK (`cli/client.ts` + `web/src/lib/api.ts`).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Synchronous `PreviewsClient` and asynchronous `AsyncPreviewsClient`.
|
|
12
|
+
- Resources: `vms`, `snapshots`, `drives`, `integrations`, `detect`, `accounts`, `system`.
|
|
13
|
+
- VM lifecycle: `create`, `list`, `get`, `destroy`, `restart`, `redeploy`,
|
|
14
|
+
`run`, `logs` (SSE), `get_env`, `set_env`, `bandwidth`, `persist`,
|
|
15
|
+
`unpersist`, `rename_slug`, `slug_available`, `upload_files`,
|
|
16
|
+
`mint_widget_token`, `wait_until_running`, `create_from_folder`.
|
|
17
|
+
- Folder → zip archiving (`archive.zip_directory`) faithful to `mcp/src/archive.ts`:
|
|
18
|
+
honors `.gitignore` via `git ls-files`, strips secrets, force-includes
|
|
19
|
+
`.env.example`, 50 MiB compressed cap, uncompressed tripwire, fingerprint cache.
|
|
20
|
+
- SSE parsing for named-event logs and unnamed detect streams.
|
|
21
|
+
- Frozen-dataclass models with `from_dict` (camelCase → snake_case, tolerant of
|
|
22
|
+
unknown keys) and a shipped `py.typed` marker.
|
|
23
|
+
- Backend widget proxy (`previews.proxy`) with FastAPI and Flask adapters.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Treinta Previews
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: treinta-previews
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Propie / Previews VM-as-a-Service platform (Firecracker microVMs).
|
|
5
|
+
Project-URL: Homepage, https://previews.amapola.treinta.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/justfedec/previews
|
|
7
|
+
Author-email: Treinta Previews <carrizofg@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: firecracker,microvm,previews,propie,sdk,vm
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx<1,>=0.27
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: anyio; extra == 'dev'
|
|
26
|
+
Requires-Dist: build; extra == 'dev'
|
|
27
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
31
|
+
Requires-Dist: twine; extra == 'dev'
|
|
32
|
+
Provides-Extra: fastapi
|
|
33
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
34
|
+
Provides-Extra: flask
|
|
35
|
+
Requires-Dist: flask>=2.2; extra == 'flask'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# treinta-previews (Python SDK)
|
|
39
|
+
|
|
40
|
+
Python SDK for the **Propie / Previews** VM-as-a-Service platform — launch
|
|
41
|
+
Firecracker microVMs from a git repo or a local folder, run commands, stream
|
|
42
|
+
logs, attach drives and databases, take snapshots, promote to permanent
|
|
43
|
+
(scale-to-zero) previews, and embed a browser widget safely.
|
|
44
|
+
|
|
45
|
+
It mirrors the Node SDK (`cli/client.ts` + `web/src/lib/api.ts`) 1:1 in surface
|
|
46
|
+
and naming, translated to Pythonic snake_case.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install treinta-previews
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **Distribution name:** `treinta-previews`
|
|
55
|
+
- **Import name:** `previews`
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from previews import PreviewsClient
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> If the top-level name `previews` ever collides with another package in your
|
|
62
|
+
> environment, the intended fallback import name is `treinta_previews`. This
|
|
63
|
+
> release publishes the package as `previews`; use a virtualenv to avoid
|
|
64
|
+
> collisions.
|
|
65
|
+
|
|
66
|
+
Optional extras:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install "treinta-previews[fastapi]" # widget proxy FastAPI adapter
|
|
70
|
+
pip install "treinta-previews[flask]" # widget proxy Flask adapter
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Authentication
|
|
74
|
+
|
|
75
|
+
The client resolves credentials from arguments or the environment:
|
|
76
|
+
|
|
77
|
+
| Setting | Argument | Env vars (in precedence order) | Default |
|
|
78
|
+
| -------- | ---------- | -------------------------------------------------------------------- | ------- |
|
|
79
|
+
| API key | `api_key` | `TREINTA_PREVIEWS_API_KEY`, `PROPIE_API_KEY`, `PREVIEWS_API_KEY` | — |
|
|
80
|
+
| Base URL | `base_url` | `TREINTA_PREVIEWS_API_URL`, `PROPIE_API_URL` | `https://previews.amapola.treinta.ai/api` |
|
|
81
|
+
|
|
82
|
+
Keys look like `pvk_<prefix>_<secret>` and are sent as `Authorization: Bearer pvk_...`.
|
|
83
|
+
|
|
84
|
+
## Quick start
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from previews import PreviewsClient
|
|
88
|
+
|
|
89
|
+
with PreviewsClient() as client: # api key from env
|
|
90
|
+
vm = client.vms.create(
|
|
91
|
+
repo_url="https://github.com/owner/repo",
|
|
92
|
+
stack="node20",
|
|
93
|
+
exposed_port=3000,
|
|
94
|
+
environment_variables={"NODE_ENV": "production"},
|
|
95
|
+
)
|
|
96
|
+
vm = client.vms.wait_until_running(vm.id)
|
|
97
|
+
print(vm.url)
|
|
98
|
+
|
|
99
|
+
result = client.vms.run(vm.id, "npm test")
|
|
100
|
+
print(result.exit_code, result.stdout)
|
|
101
|
+
|
|
102
|
+
for event in client.vms.logs(vm.id, follow=False):
|
|
103
|
+
print(event.message)
|
|
104
|
+
|
|
105
|
+
client.vms.destroy(vm.id)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Deploy a local folder (zipped locally, honoring `.gitignore`, stripping secrets):
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
det = client.detect.folder("/path/to/app") # SSE stack detection
|
|
112
|
+
vm = client.vms.create_from_folder(
|
|
113
|
+
"/path/to/app",
|
|
114
|
+
stack=det.stack,
|
|
115
|
+
start_command=det.start_command,
|
|
116
|
+
exposed_port=det.exposed_port,
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Async
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from previews import AsyncPreviewsClient
|
|
124
|
+
|
|
125
|
+
async with AsyncPreviewsClient() as client:
|
|
126
|
+
vms = await client.vms.list()
|
|
127
|
+
async for event in client.vms.logs(vms[0].id, follow=False):
|
|
128
|
+
print(event.message)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## API surface
|
|
132
|
+
|
|
133
|
+
`PreviewsClient(api_key=None, *, base_url=None, timeout=30.0, http_client=None)`
|
|
134
|
+
(and the identical `AsyncPreviewsClient` with `await`/async generators/`aclose()`).
|
|
135
|
+
Both are context managers and expose `request(method, path, *, json=None, ...)`
|
|
136
|
+
plus the resources below.
|
|
137
|
+
|
|
138
|
+
### `client.vms`
|
|
139
|
+
|
|
140
|
+
| Method | REST |
|
|
141
|
+
| --- | --- |
|
|
142
|
+
| `create(**fields)` | `POST /vms` (json) |
|
|
143
|
+
| `create_from_folder(path, **meta)` / `create_from_zip(zip_bytes, **meta)` | `POST /vms` (multipart) |
|
|
144
|
+
| `list()` | `GET /vms` |
|
|
145
|
+
| `get(id)` | `GET /vms/:id` |
|
|
146
|
+
| `destroy(id)` | `DELETE /vms/:id` |
|
|
147
|
+
| `restart(id)` | `POST /vms/:id/restart` |
|
|
148
|
+
| `redeploy(id)` | `POST /vms/:id/redeploy` |
|
|
149
|
+
| `run(id, command, *, timeout=300.0)` | `POST /vms/:id/run` (buffered) |
|
|
150
|
+
| `logs(id, *, follow=True)` | `GET /vms/:id/logs` (SSE) → `Iterator[LogEvent]` |
|
|
151
|
+
| `get_env(id)` / `set_env(id, env)` | `GET`/`PUT /vms/:id/env` |
|
|
152
|
+
| `bandwidth(id)` | `GET /vms/:id/bandwidth` |
|
|
153
|
+
| `persist(id, slug)` / `unpersist(id)` / `rename_slug(id, slug)` | `/vms/:id/persist`, `/vms/:id/slug` |
|
|
154
|
+
| `slug_available(slug)` → `(bool, reason?)` | `GET /vms/slug-available` |
|
|
155
|
+
| `upload_files(id, files, *, base_dir="/app")` | `POST /vms/:id/files` |
|
|
156
|
+
| `mint_widget_token(id, *, capabilities=None, ttl_seconds=None)` | `POST /vms/:id/widget-token` |
|
|
157
|
+
| `wait_until_running(id, *, timeout=300.0, interval=2.0, on_status=None)` | polls `GET /vms/:id` |
|
|
158
|
+
|
|
159
|
+
`create` / `create_from_*` accept snake_case fields: `repo_url`, `stack`,
|
|
160
|
+
`branch`, `subdirectory`, `exposed_port`, `install_command`, `start_command`,
|
|
161
|
+
`environment_variables`, `drive_id`, `drive_mount_path`, `drive_read_only`,
|
|
162
|
+
`database_integration_id`, `vcpus`, `memory_mib`, `persistent`, `slug`.
|
|
163
|
+
|
|
164
|
+
### `client.snapshots` / `.drives` / `.integrations` / `.detect` / `.accounts`
|
|
165
|
+
|
|
166
|
+
- `snapshots.list() / create(vm_id, name=None) / clone(snapshot_id, *, name=None, environment_variables=None) / delete(snapshot_id)`
|
|
167
|
+
- `drives.list() / create(name, size_gib, *, mount_path=None) / delete(id)`
|
|
168
|
+
- `integrations.list() / create(**fields) / delete(id)`
|
|
169
|
+
- `detect.public(repo_url, *, on_progress=None) / zip(zip_bytes, ...) / folder(path, ...)`
|
|
170
|
+
- `accounts.current()` → `CurrentPrincipal(account, project, auth_type, api_key)`
|
|
171
|
+
|
|
172
|
+
### System status
|
|
173
|
+
|
|
174
|
+
Not a dedicated resource; use the generic request helper:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from previews import SystemStatus
|
|
178
|
+
status = SystemStatus.from_dict(client.request("GET", "/system/status"))
|
|
179
|
+
print(status.caches["npm"].size_bytes)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Errors
|
|
183
|
+
|
|
184
|
+
Non-2xx responses raise `PreviewsApiError(status, code, message)` from the
|
|
185
|
+
`{ error: { code, message } }` envelope (a non-JSON body yields code `UNKNOWN`).
|
|
186
|
+
`wait_until_running` raises `PreviewsTimeoutError` on deadline. Missing
|
|
187
|
+
credentials raise `PreviewsConfigError`.
|
|
188
|
+
|
|
189
|
+
## Models
|
|
190
|
+
|
|
191
|
+
Responses are frozen dataclasses with a tolerant `from_dict` (camelCase →
|
|
192
|
+
snake_case, unknown keys ignored): `Preview` (alias `VM`), `RunCommandResult`,
|
|
193
|
+
`VMSnapshot`, `DetectionResult`/`EnvVarHint`, `BandwidthSample`/`BandwidthResponse`,
|
|
194
|
+
`Account`/`Project`/`ApiKeyRef`/`CurrentPrincipal`, `DatabaseIntegration`,
|
|
195
|
+
`Drive`, `SystemStatus`/`CacheStat`, `WidgetToken`, `UploadResult`, `LogEvent`.
|
|
196
|
+
The package ships `py.typed`.
|
|
197
|
+
|
|
198
|
+
## Widget backend proxy
|
|
199
|
+
|
|
200
|
+
The React widget UI is built separately. This SDK provides the backend piece.
|
|
201
|
+
|
|
202
|
+
**Scoped token (recommended):** mint a short-lived `pwt_` token server-side and
|
|
203
|
+
hand it to the browser, which then calls the platform directly.
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
token = client.vms.mint_widget_token(vm_id, capabilities=["preview:read", "vm:run"])
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Backend proxy:** the browser calls *your* server; the proxy forwards only a
|
|
210
|
+
whitelisted subset of operations to the platform using the `pvk_` key it holds —
|
|
211
|
+
the key never reaches the browser.
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from previews import PreviewsClient
|
|
215
|
+
from previews.proxy import PreviewsProxy, ProxyConfig, make_router # or make_blueprint
|
|
216
|
+
|
|
217
|
+
client = PreviewsClient()
|
|
218
|
+
proxy = PreviewsProxy(ProxyConfig(
|
|
219
|
+
client=client,
|
|
220
|
+
allowed_origins=["https://app.example.com"],
|
|
221
|
+
allowed_vm_ids={"<vm-uuid>"}, # None = any VM in the project
|
|
222
|
+
# allow_ops defaults to WIDGET_SAFE_OPS = {"status","run","files","logs","mint_token"}
|
|
223
|
+
))
|
|
224
|
+
|
|
225
|
+
# FastAPI
|
|
226
|
+
from fastapi import FastAPI
|
|
227
|
+
app = FastAPI()
|
|
228
|
+
app.include_router(make_router(proxy, prefix="/previews"))
|
|
229
|
+
|
|
230
|
+
# Flask
|
|
231
|
+
# from flask import Flask
|
|
232
|
+
# app = Flask(__name__)
|
|
233
|
+
# app.register_blueprint(make_blueprint(proxy, url_prefix="/previews"))
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The proxy enforces the origin allowlist, the op whitelist (hard-capped to the
|
|
237
|
+
widget-safe set), and the per-VM restriction; it never echoes the `pvk_` key.
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# treinta-previews (Python SDK)
|
|
2
|
+
|
|
3
|
+
Python SDK for the **Propie / Previews** VM-as-a-Service platform — launch
|
|
4
|
+
Firecracker microVMs from a git repo or a local folder, run commands, stream
|
|
5
|
+
logs, attach drives and databases, take snapshots, promote to permanent
|
|
6
|
+
(scale-to-zero) previews, and embed a browser widget safely.
|
|
7
|
+
|
|
8
|
+
It mirrors the Node SDK (`cli/client.ts` + `web/src/lib/api.ts`) 1:1 in surface
|
|
9
|
+
and naming, translated to Pythonic snake_case.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install treinta-previews
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **Distribution name:** `treinta-previews`
|
|
18
|
+
- **Import name:** `previews`
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from previews import PreviewsClient
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
> If the top-level name `previews` ever collides with another package in your
|
|
25
|
+
> environment, the intended fallback import name is `treinta_previews`. This
|
|
26
|
+
> release publishes the package as `previews`; use a virtualenv to avoid
|
|
27
|
+
> collisions.
|
|
28
|
+
|
|
29
|
+
Optional extras:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install "treinta-previews[fastapi]" # widget proxy FastAPI adapter
|
|
33
|
+
pip install "treinta-previews[flask]" # widget proxy Flask adapter
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Authentication
|
|
37
|
+
|
|
38
|
+
The client resolves credentials from arguments or the environment:
|
|
39
|
+
|
|
40
|
+
| Setting | Argument | Env vars (in precedence order) | Default |
|
|
41
|
+
| -------- | ---------- | -------------------------------------------------------------------- | ------- |
|
|
42
|
+
| API key | `api_key` | `TREINTA_PREVIEWS_API_KEY`, `PROPIE_API_KEY`, `PREVIEWS_API_KEY` | — |
|
|
43
|
+
| Base URL | `base_url` | `TREINTA_PREVIEWS_API_URL`, `PROPIE_API_URL` | `https://previews.amapola.treinta.ai/api` |
|
|
44
|
+
|
|
45
|
+
Keys look like `pvk_<prefix>_<secret>` and are sent as `Authorization: Bearer pvk_...`.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from previews import PreviewsClient
|
|
51
|
+
|
|
52
|
+
with PreviewsClient() as client: # api key from env
|
|
53
|
+
vm = client.vms.create(
|
|
54
|
+
repo_url="https://github.com/owner/repo",
|
|
55
|
+
stack="node20",
|
|
56
|
+
exposed_port=3000,
|
|
57
|
+
environment_variables={"NODE_ENV": "production"},
|
|
58
|
+
)
|
|
59
|
+
vm = client.vms.wait_until_running(vm.id)
|
|
60
|
+
print(vm.url)
|
|
61
|
+
|
|
62
|
+
result = client.vms.run(vm.id, "npm test")
|
|
63
|
+
print(result.exit_code, result.stdout)
|
|
64
|
+
|
|
65
|
+
for event in client.vms.logs(vm.id, follow=False):
|
|
66
|
+
print(event.message)
|
|
67
|
+
|
|
68
|
+
client.vms.destroy(vm.id)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Deploy a local folder (zipped locally, honoring `.gitignore`, stripping secrets):
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
det = client.detect.folder("/path/to/app") # SSE stack detection
|
|
75
|
+
vm = client.vms.create_from_folder(
|
|
76
|
+
"/path/to/app",
|
|
77
|
+
stack=det.stack,
|
|
78
|
+
start_command=det.start_command,
|
|
79
|
+
exposed_port=det.exposed_port,
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Async
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from previews import AsyncPreviewsClient
|
|
87
|
+
|
|
88
|
+
async with AsyncPreviewsClient() as client:
|
|
89
|
+
vms = await client.vms.list()
|
|
90
|
+
async for event in client.vms.logs(vms[0].id, follow=False):
|
|
91
|
+
print(event.message)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## API surface
|
|
95
|
+
|
|
96
|
+
`PreviewsClient(api_key=None, *, base_url=None, timeout=30.0, http_client=None)`
|
|
97
|
+
(and the identical `AsyncPreviewsClient` with `await`/async generators/`aclose()`).
|
|
98
|
+
Both are context managers and expose `request(method, path, *, json=None, ...)`
|
|
99
|
+
plus the resources below.
|
|
100
|
+
|
|
101
|
+
### `client.vms`
|
|
102
|
+
|
|
103
|
+
| Method | REST |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `create(**fields)` | `POST /vms` (json) |
|
|
106
|
+
| `create_from_folder(path, **meta)` / `create_from_zip(zip_bytes, **meta)` | `POST /vms` (multipart) |
|
|
107
|
+
| `list()` | `GET /vms` |
|
|
108
|
+
| `get(id)` | `GET /vms/:id` |
|
|
109
|
+
| `destroy(id)` | `DELETE /vms/:id` |
|
|
110
|
+
| `restart(id)` | `POST /vms/:id/restart` |
|
|
111
|
+
| `redeploy(id)` | `POST /vms/:id/redeploy` |
|
|
112
|
+
| `run(id, command, *, timeout=300.0)` | `POST /vms/:id/run` (buffered) |
|
|
113
|
+
| `logs(id, *, follow=True)` | `GET /vms/:id/logs` (SSE) → `Iterator[LogEvent]` |
|
|
114
|
+
| `get_env(id)` / `set_env(id, env)` | `GET`/`PUT /vms/:id/env` |
|
|
115
|
+
| `bandwidth(id)` | `GET /vms/:id/bandwidth` |
|
|
116
|
+
| `persist(id, slug)` / `unpersist(id)` / `rename_slug(id, slug)` | `/vms/:id/persist`, `/vms/:id/slug` |
|
|
117
|
+
| `slug_available(slug)` → `(bool, reason?)` | `GET /vms/slug-available` |
|
|
118
|
+
| `upload_files(id, files, *, base_dir="/app")` | `POST /vms/:id/files` |
|
|
119
|
+
| `mint_widget_token(id, *, capabilities=None, ttl_seconds=None)` | `POST /vms/:id/widget-token` |
|
|
120
|
+
| `wait_until_running(id, *, timeout=300.0, interval=2.0, on_status=None)` | polls `GET /vms/:id` |
|
|
121
|
+
|
|
122
|
+
`create` / `create_from_*` accept snake_case fields: `repo_url`, `stack`,
|
|
123
|
+
`branch`, `subdirectory`, `exposed_port`, `install_command`, `start_command`,
|
|
124
|
+
`environment_variables`, `drive_id`, `drive_mount_path`, `drive_read_only`,
|
|
125
|
+
`database_integration_id`, `vcpus`, `memory_mib`, `persistent`, `slug`.
|
|
126
|
+
|
|
127
|
+
### `client.snapshots` / `.drives` / `.integrations` / `.detect` / `.accounts`
|
|
128
|
+
|
|
129
|
+
- `snapshots.list() / create(vm_id, name=None) / clone(snapshot_id, *, name=None, environment_variables=None) / delete(snapshot_id)`
|
|
130
|
+
- `drives.list() / create(name, size_gib, *, mount_path=None) / delete(id)`
|
|
131
|
+
- `integrations.list() / create(**fields) / delete(id)`
|
|
132
|
+
- `detect.public(repo_url, *, on_progress=None) / zip(zip_bytes, ...) / folder(path, ...)`
|
|
133
|
+
- `accounts.current()` → `CurrentPrincipal(account, project, auth_type, api_key)`
|
|
134
|
+
|
|
135
|
+
### System status
|
|
136
|
+
|
|
137
|
+
Not a dedicated resource; use the generic request helper:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from previews import SystemStatus
|
|
141
|
+
status = SystemStatus.from_dict(client.request("GET", "/system/status"))
|
|
142
|
+
print(status.caches["npm"].size_bytes)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Errors
|
|
146
|
+
|
|
147
|
+
Non-2xx responses raise `PreviewsApiError(status, code, message)` from the
|
|
148
|
+
`{ error: { code, message } }` envelope (a non-JSON body yields code `UNKNOWN`).
|
|
149
|
+
`wait_until_running` raises `PreviewsTimeoutError` on deadline. Missing
|
|
150
|
+
credentials raise `PreviewsConfigError`.
|
|
151
|
+
|
|
152
|
+
## Models
|
|
153
|
+
|
|
154
|
+
Responses are frozen dataclasses with a tolerant `from_dict` (camelCase →
|
|
155
|
+
snake_case, unknown keys ignored): `Preview` (alias `VM`), `RunCommandResult`,
|
|
156
|
+
`VMSnapshot`, `DetectionResult`/`EnvVarHint`, `BandwidthSample`/`BandwidthResponse`,
|
|
157
|
+
`Account`/`Project`/`ApiKeyRef`/`CurrentPrincipal`, `DatabaseIntegration`,
|
|
158
|
+
`Drive`, `SystemStatus`/`CacheStat`, `WidgetToken`, `UploadResult`, `LogEvent`.
|
|
159
|
+
The package ships `py.typed`.
|
|
160
|
+
|
|
161
|
+
## Widget backend proxy
|
|
162
|
+
|
|
163
|
+
The React widget UI is built separately. This SDK provides the backend piece.
|
|
164
|
+
|
|
165
|
+
**Scoped token (recommended):** mint a short-lived `pwt_` token server-side and
|
|
166
|
+
hand it to the browser, which then calls the platform directly.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
token = client.vms.mint_widget_token(vm_id, capabilities=["preview:read", "vm:run"])
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Backend proxy:** the browser calls *your* server; the proxy forwards only a
|
|
173
|
+
whitelisted subset of operations to the platform using the `pvk_` key it holds —
|
|
174
|
+
the key never reaches the browser.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from previews import PreviewsClient
|
|
178
|
+
from previews.proxy import PreviewsProxy, ProxyConfig, make_router # or make_blueprint
|
|
179
|
+
|
|
180
|
+
client = PreviewsClient()
|
|
181
|
+
proxy = PreviewsProxy(ProxyConfig(
|
|
182
|
+
client=client,
|
|
183
|
+
allowed_origins=["https://app.example.com"],
|
|
184
|
+
allowed_vm_ids={"<vm-uuid>"}, # None = any VM in the project
|
|
185
|
+
# allow_ops defaults to WIDGET_SAFE_OPS = {"status","run","files","logs","mint_token"}
|
|
186
|
+
))
|
|
187
|
+
|
|
188
|
+
# FastAPI
|
|
189
|
+
from fastapi import FastAPI
|
|
190
|
+
app = FastAPI()
|
|
191
|
+
app.include_router(make_router(proxy, prefix="/previews"))
|
|
192
|
+
|
|
193
|
+
# Flask
|
|
194
|
+
# from flask import Flask
|
|
195
|
+
# app = Flask(__name__)
|
|
196
|
+
# app.register_blueprint(make_blueprint(proxy, url_prefix="/previews"))
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The proxy enforces the origin allowlist, the op whitelist (hard-capped to the
|
|
200
|
+
widget-safe set), and the per-VM restriction; it never echoes the `pvk_` key.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "treinta-previews"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python SDK for the Propie / Previews VM-as-a-Service platform (Firecracker microVMs)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Treinta Previews", email = "carrizofg@gmail.com" }]
|
|
13
|
+
keywords = ["previews", "propie", "firecracker", "vm", "microvm", "sdk"]
|
|
14
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
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
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://previews.amapola.treinta.ai"
|
|
31
|
+
Repository = "https://github.com/justfedec/previews"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
fastapi = ["fastapi>=0.100"]
|
|
35
|
+
flask = ["flask>=2.2"]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8",
|
|
38
|
+
"pytest-asyncio",
|
|
39
|
+
"anyio",
|
|
40
|
+
"mypy",
|
|
41
|
+
"ruff",
|
|
42
|
+
"build",
|
|
43
|
+
"twine",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.version]
|
|
47
|
+
path = "src/previews/_version.py"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/previews"]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = [
|
|
54
|
+
"src",
|
|
55
|
+
"tests",
|
|
56
|
+
"scripts",
|
|
57
|
+
"README.md",
|
|
58
|
+
"LICENSE",
|
|
59
|
+
"CHANGELOG.md",
|
|
60
|
+
"pyproject.toml",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
asyncio_mode = "auto"
|
|
65
|
+
testpaths = ["tests"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff]
|
|
68
|
+
line-length = 100
|
|
69
|
+
target-version = "py39"
|
|
70
|
+
|
|
71
|
+
# UP (pyupgrade) is intentionally not selected: the SDK targets Python 3.9, so
|
|
72
|
+
# it uses typing.List/Dict/Optional rather than PEP 585/604 builtins.
|
|
73
|
+
[tool.ruff.lint]
|
|
74
|
+
select = ["E", "F", "I", "B"]
|
|
75
|
+
ignore = ["E501"]
|
|
76
|
+
|
|
77
|
+
[tool.mypy]
|
|
78
|
+
python_version = "3.10"
|
|
79
|
+
warn_unused_ignores = true
|
|
80
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""End-to-end smoke test against a live Previews API.
|
|
3
|
+
|
|
4
|
+
Exercises the full happy path:
|
|
5
|
+
|
|
6
|
+
accounts.current -> detect -> create -> wait_until_running -> run
|
|
7
|
+
-> logs -> upload_files -> mint_widget_token -> destroy
|
|
8
|
+
|
|
9
|
+
Run it manually (it is NOT part of the offline pytest suite)::
|
|
10
|
+
|
|
11
|
+
export TREINTA_PREVIEWS_API_KEY=pvk_...
|
|
12
|
+
python scripts/smoke.py https://github.com/vercel/next.js
|
|
13
|
+
|
|
14
|
+
This module must import and parse cleanly even without an API key.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from previews import PreviewsClient
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(repo_url: str) -> int:
|
|
25
|
+
with PreviewsClient() as client:
|
|
26
|
+
principal = client.accounts.current()
|
|
27
|
+
acct = principal.account.name if principal.account else "?"
|
|
28
|
+
print(f"[accounts] authenticated as account={acct} project={principal.project.slug if principal.project else '?'}")
|
|
29
|
+
|
|
30
|
+
print(f"[detect] {repo_url} ...")
|
|
31
|
+
detection = client.detect.public(repo_url, on_progress=lambda m: print(f" .. {m}"))
|
|
32
|
+
print(f"[detect] stack={detection.stack} start={detection.start_command!r} port={detection.exposed_port}")
|
|
33
|
+
|
|
34
|
+
print("[create] launching VM ...")
|
|
35
|
+
vm = client.vms.create(
|
|
36
|
+
repo_url=detection.repo_url or repo_url,
|
|
37
|
+
stack=detection.stack,
|
|
38
|
+
branch=detection.branch or "main",
|
|
39
|
+
exposed_port=detection.exposed_port or 3000,
|
|
40
|
+
install_command=detection.install_command,
|
|
41
|
+
start_command=detection.start_command,
|
|
42
|
+
)
|
|
43
|
+
print(f"[create] id={vm.id} status={vm.status}")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
print("[wait] polling until running ...")
|
|
47
|
+
vm = client.vms.wait_until_running(
|
|
48
|
+
vm.id, on_status=lambda v: print(f" .. status={v.status}")
|
|
49
|
+
)
|
|
50
|
+
print(f"[wait] running at {vm.url}")
|
|
51
|
+
|
|
52
|
+
result = client.vms.run(vm.id, "echo hello && uname -a")
|
|
53
|
+
print(f"[run] exit={result.exit_code} out={result.stdout.strip()!r}")
|
|
54
|
+
|
|
55
|
+
print("[logs] tailing (first buffered burst) ...")
|
|
56
|
+
for i, event in enumerate(client.vms.logs(vm.id, follow=False)):
|
|
57
|
+
print(f" [{event.event}] {event.message}")
|
|
58
|
+
if i > 20:
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
upload = client.vms.upload_files(
|
|
62
|
+
vm.id, {"smoke.txt": "written by scripts/smoke.py\n"}, base_dir="/app"
|
|
63
|
+
)
|
|
64
|
+
print(f"[files] wrote {upload.written} file(s): {upload.paths}")
|
|
65
|
+
|
|
66
|
+
token = client.vms.mint_widget_token(vm.id, capabilities=["preview:read"], ttl_seconds=300)
|
|
67
|
+
print(f"[widget] token={token.token[:12]}... expires={token.expires_at}")
|
|
68
|
+
finally:
|
|
69
|
+
destroyed = client.vms.destroy(vm.id)
|
|
70
|
+
print(f"[destroy] id={destroyed.id} status={destroyed.status}")
|
|
71
|
+
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
if len(sys.argv) < 2:
|
|
77
|
+
print("usage: python scripts/smoke.py <public-github-repo-url>", file=sys.stderr)
|
|
78
|
+
raise SystemExit(2)
|
|
79
|
+
raise SystemExit(main(sys.argv[1]))
|