treinta-previews 0.1.0__py3-none-any.whl

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.
previews/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """Python SDK for the Propie / Previews VM-as-a-Service platform.
2
+
3
+ Quick start::
4
+
5
+ from previews import PreviewsClient
6
+
7
+ with PreviewsClient() as client: # api key from env
8
+ vm = client.vms.create(repo_url="https://github.com/owner/repo", stack="node20")
9
+ vm = client.vms.wait_until_running(vm.id)
10
+ print(vm.url)
11
+
12
+ Async::
13
+
14
+ from previews import AsyncPreviewsClient
15
+
16
+ async with AsyncPreviewsClient() as client:
17
+ vms = await client.vms.list()
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from ._version import __version__
23
+ from .archive import MAX_ZIP_BYTES, ZipResult, zip_directory
24
+ from .async_client import AsyncPreviewsClient
25
+ from .client import PreviewsClient
26
+ from .errors import (
27
+ PreviewsApiError,
28
+ PreviewsConfigError,
29
+ PreviewsError,
30
+ PreviewsTimeoutError,
31
+ )
32
+ from .models import (
33
+ VM,
34
+ Account,
35
+ ApiKeyRef,
36
+ BandwidthResponse,
37
+ BandwidthSample,
38
+ CacheStat,
39
+ CurrentPrincipal,
40
+ DatabaseIntegration,
41
+ DetectionResult,
42
+ Drive,
43
+ EnvVarHint,
44
+ LogEvent,
45
+ Preview,
46
+ Project,
47
+ RunCommandResult,
48
+ SystemStatus,
49
+ UploadResult,
50
+ VMSnapshot,
51
+ WidgetToken,
52
+ )
53
+
54
+ __all__ = [
55
+ "__version__",
56
+ # Clients
57
+ "PreviewsClient",
58
+ "AsyncPreviewsClient",
59
+ # Errors
60
+ "PreviewsError",
61
+ "PreviewsApiError",
62
+ "PreviewsTimeoutError",
63
+ "PreviewsConfigError",
64
+ # Archive
65
+ "zip_directory",
66
+ "ZipResult",
67
+ "MAX_ZIP_BYTES",
68
+ # Models
69
+ "Preview",
70
+ "VM",
71
+ "RunCommandResult",
72
+ "VMSnapshot",
73
+ "LogEvent",
74
+ "EnvVarHint",
75
+ "DetectionResult",
76
+ "BandwidthSample",
77
+ "BandwidthResponse",
78
+ "Account",
79
+ "Project",
80
+ "ApiKeyRef",
81
+ "CurrentPrincipal",
82
+ "DatabaseIntegration",
83
+ "Drive",
84
+ "CacheStat",
85
+ "SystemStatus",
86
+ "WidgetToken",
87
+ "UploadResult",
88
+ ]
previews/_case.py ADDED
@@ -0,0 +1,48 @@
1
+ """camelCase <-> snake_case conversion helpers.
2
+
3
+ The platform wire format is camelCase (mirrors ``web/src/lib/api.ts``). The
4
+ Python SDK exposes snake_case names. These helpers translate between the two.
5
+
6
+ IMPORTANT: only *field names* are converted. Free-form dict *values* (env-var
7
+ maps, file contents) must never be key-mangled — callers convert the top-level
8
+ keys they know about and pass value dicts through untouched.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from typing import Any
15
+
16
+ _CAMEL_BOUNDARY_1 = re.compile(r"(.)([A-Z][a-z]+)")
17
+ _CAMEL_BOUNDARY_2 = re.compile(r"([a-z0-9])([A-Z])")
18
+
19
+
20
+ def camel_to_snake(name: str) -> str:
21
+ """``repoUrl`` -> ``repo_url``, ``memoryMib`` -> ``memory_mib``."""
22
+ s = _CAMEL_BOUNDARY_1.sub(r"\1_\2", name)
23
+ s = _CAMEL_BOUNDARY_2.sub(r"\1_\2", s)
24
+ return s.lower()
25
+
26
+
27
+ def snake_to_camel(name: str) -> str:
28
+ """``repo_url`` -> ``repoUrl``, ``drive_read_only`` -> ``driveReadOnly``.
29
+
30
+ Idempotent for names that are already camelCase (``repoUrl`` -> ``repoUrl``).
31
+ Uses ``p[:1].upper() + p[1:]`` (not ``.title()``) so segments like ``mib``
32
+ become ``Mib`` while preserving any already-upper trailing characters.
33
+ """
34
+ parts = name.split("_")
35
+ if len(parts) == 1:
36
+ return name
37
+ head = parts[0]
38
+ tail = "".join(p[:1].upper() + p[1:] for p in parts[1:] if p != "")
39
+ return head + tail
40
+
41
+
42
+ def keys_to_snake(data: Any) -> Any:
43
+ """Recursively convert every mapping key to snake_case (values untouched)."""
44
+ if isinstance(data, dict):
45
+ return {camel_to_snake(str(k)): keys_to_snake(v) for k, v in data.items()}
46
+ if isinstance(data, (list, tuple)):
47
+ return [keys_to_snake(v) for v in data]
48
+ return data
previews/_config.py ADDED
@@ -0,0 +1,45 @@
1
+ """Configuration resolution: base URL + API key from args or the environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ # Default production base. Mirrors cli/client.ts.
9
+ DEFAULT_BASE_URL = "https://previews.amapola.treinta.ai/api"
10
+
11
+ # Env var precedence mirrors the CLI: primary name first, then aliases.
12
+ API_KEY_ENV_VARS = ("TREINTA_PREVIEWS_API_KEY", "PROPIE_API_KEY", "PREVIEWS_API_KEY")
13
+ BASE_URL_ENV_VARS = ("TREINTA_PREVIEWS_API_URL", "PROPIE_API_URL")
14
+
15
+ # Canonical terminal VM statuses (single source of truth mirrors
16
+ # api/src/lib/constants.ts::TERMINAL_STATUSES).
17
+ TERMINAL_STATUSES = frozenset({"error", "stopped", "deleted"})
18
+
19
+ # Non-terminal but compute-free (scale-to-zero permanent previews).
20
+ DORMANT_STATUSES = frozenset({"sleeping"})
21
+
22
+
23
+ def resolve_api_key(explicit: Optional[str]) -> Optional[str]:
24
+ """Explicit arg wins; otherwise the first non-empty env var in precedence."""
25
+ if explicit:
26
+ return explicit
27
+ for name in API_KEY_ENV_VARS:
28
+ val = os.environ.get(name)
29
+ if val:
30
+ return val
31
+ return None
32
+
33
+
34
+ def resolve_base_url(explicit: Optional[str]) -> str:
35
+ """Explicit arg wins, then env vars, then the default. Trailing slash stripped."""
36
+ base = explicit
37
+ if not base:
38
+ for name in BASE_URL_ENV_VARS:
39
+ val = os.environ.get(name)
40
+ if val:
41
+ base = val
42
+ break
43
+ if not base:
44
+ base = DEFAULT_BASE_URL
45
+ return base.rstrip("/")
previews/_http.py ADDED
@@ -0,0 +1,62 @@
1
+ """Transport-agnostic HTTP helpers shared by the sync and async clients.
2
+
3
+ These are pure functions (no I/O) so the sync client, the async client, and the
4
+ SSE layer all raise the identical error envelope and parse bodies identically.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json as _json
10
+ from typing import Any, Dict, Mapping, Optional
11
+ from urllib.parse import quote
12
+
13
+ from .errors import PreviewsApiError
14
+
15
+
16
+ def build_headers(api_key: str, extra: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
17
+ """Authorization header + any extras. Never sets Content-Type (httpx does)."""
18
+ headers: Dict[str, str] = {"Authorization": f"Bearer {api_key}"}
19
+ if extra:
20
+ headers.update(extra)
21
+ return headers
22
+
23
+
24
+ def parse_json(text: str) -> Any:
25
+ """Parse a response body. Empty -> ``{}``; non-JSON -> ``{"raw": text}``."""
26
+ if not text:
27
+ return {}
28
+ try:
29
+ return _json.loads(text)
30
+ except ValueError:
31
+ return {"raw": text}
32
+
33
+
34
+ def raise_for_status(status_code: int, reason: str, body: Any) -> None:
35
+ """Raise :class:`PreviewsApiError` from a parsed error envelope.
36
+
37
+ Envelope shape: ``{ error: { code, message } }``. Fallback code ``"UNKNOWN"``,
38
+ fallback message ``"<status> <reason>"``.
39
+ """
40
+ err = body.get("error") if isinstance(body, Mapping) else None
41
+ err = err if isinstance(err, Mapping) else {}
42
+ code = err.get("code") or "UNKNOWN"
43
+ message = err.get("message") or f"{status_code} {reason}".strip()
44
+ raise PreviewsApiError(status_code, code, message)
45
+
46
+
47
+ def process_response(status_code: int, reason: str, text: str, is_success: bool) -> Any:
48
+ """Parse + (on non-2xx) raise. Returns the parsed body on success."""
49
+ body = parse_json(text)
50
+ if not is_success:
51
+ raise_for_status(status_code, reason, body)
52
+ return body
53
+
54
+
55
+ def drop_none(data: Mapping[str, Any]) -> Dict[str, Any]:
56
+ """Return a copy of ``data`` with ``None`` values removed (top level only)."""
57
+ return {k: v for k, v in data.items() if v is not None}
58
+
59
+
60
+ def quote_id(value: Any) -> str:
61
+ """URL-encode a path segment (mirrors ``encodeURIComponent``)."""
62
+ return quote(str(value), safe="")
previews/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version (read by hatchling)."""
2
+
3
+ __version__ = "0.1.0"
previews/archive.py ADDED
@@ -0,0 +1,271 @@
1
+ """Zip a local project directory for upload to the Previews API.
2
+
3
+ Faithful port of ``mcp/src/archive.ts``:
4
+
5
+ * honors ``.gitignore`` when the folder is a git repo (``git ls-files`` union of
6
+ tracked + untracked-not-ignored), otherwise a pruned filesystem walk;
7
+ * force-includes the ``.env.example`` family (needed for stack detection) even
8
+ when the repo gitignores the whole ``.env*`` family;
9
+ * always strips secret-bearing files (``.env``, ``*.pem``/``*.key``, ``id_rsa``,
10
+ ``credentials.json``, ``.ssh``/``.aws``/``.gnupg`` dirs, ...) regardless of
11
+ source — secrets must travel via ``environment_variables``, never the archive;
12
+ * enforces the 50 MiB *compressed* upload cap and a 384 MiB *uncompressed*
13
+ tripwire (catches "deploying the wrong folder");
14
+ * caches the produced archive per absolute root, keyed by a cheap content
15
+ fingerprint, so detect-then-create on an unchanged folder reuses the bytes.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import json
22
+ import os
23
+ import re
24
+ import subprocess
25
+ import zipfile
26
+ from dataclasses import dataclass, field, replace
27
+ from typing import Dict, List, Optional, Tuple
28
+
29
+ # Matches the server's MAX_CODE_ARCHIVE_BYTES. Checked on the COMPRESSED zip.
30
+ MAX_ZIP_BYTES = 50 * 1024 * 1024
31
+ # Peak-memory tripwire on the UNCOMPRESSED total (whole archive built in memory).
32
+ UNCOMPRESSED_TRIPWIRE = 384 * 1024 * 1024
33
+
34
+ # Basenames pruned in the non-git fallback walk (unused when .gitignore drives it).
35
+ DEFAULT_IGNORES = {
36
+ ".git", "node_modules", ".next", "dist", "build", "out", ".turbo", ".cache",
37
+ ".vercel", ".netlify", "coverage", "__pycache__", ".venv", "venv",
38
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", ".pnpm-store", ".yarn",
39
+ ".svelte-kit", ".parcel-cache", ".gradle", "target", "vendor", "tmp",
40
+ ".idea", ".vscode", ".DS_Store", "Thumbs.db",
41
+ }
42
+
43
+ # Credential-store directories, always stripped even from git-tracked files.
44
+ SECRET_DIRS = {".ssh", ".aws", ".gnupg"}
45
+
46
+ # .env variants that carry NO secrets — kept for the server's detector.
47
+ ENV_ALLOWLIST = {".env.example", ".env.sample", ".env.template", ".env.dist"}
48
+
49
+ _SECRET_FILE_RE = [
50
+ re.compile(r"\.(pem|key|p12|pfx|keystore|asc)$", re.IGNORECASE),
51
+ re.compile(r"^id_(rsa|ed25519|ecdsa|dsa)\b", re.IGNORECASE),
52
+ re.compile(r"(^|[._-])credentials?([._-].*)?\.json$", re.IGNORECASE),
53
+ re.compile(r"^service[-_]?account.*\.json$", re.IGNORECASE),
54
+ ]
55
+ _SECRET_FILE_NAMES = {".npmrc", ".netrc", ".git-credentials", ".pgpass"}
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ZipResult:
60
+ zip: bytes
61
+ file_count: int
62
+ bytes: int
63
+ root: str
64
+ warnings: List[str] = field(default_factory=list)
65
+
66
+
67
+ # Per-absolute-root cache, validated by a content fingerprint.
68
+ _cache: Dict[str, Tuple[str, ZipResult]] = {}
69
+
70
+
71
+ def _is_secret_file(base: str) -> bool:
72
+ if base == ".env":
73
+ return True
74
+ if base.startswith(".env.") and base not in ENV_ALLOWLIST:
75
+ return True
76
+ if base in _SECRET_FILE_NAMES:
77
+ return True
78
+ return any(rx.search(base) for rx in _SECRET_FILE_RE)
79
+
80
+
81
+ def _classify_secret(rel: str) -> Optional[str]:
82
+ parts = rel.split("/")
83
+ for p in parts:
84
+ if p in SECRET_DIRS:
85
+ return f"credential dir '{p}/'"
86
+ if _is_secret_file(parts[-1]):
87
+ return "secret/credential file"
88
+ return None
89
+
90
+
91
+ def _is_git_repo(root_abs: str) -> bool:
92
+ try:
93
+ out = subprocess.run(
94
+ ["git", "-C", root_abs, "rev-parse", "--is-inside-work-tree"],
95
+ capture_output=True,
96
+ text=True,
97
+ )
98
+ return out.returncode == 0 and out.stdout.strip() == "true"
99
+ except (FileNotFoundError, OSError):
100
+ return False
101
+
102
+
103
+ def _git_files(root_abs: str, extra_args: List[str]) -> List[str]:
104
+ out = subprocess.run(
105
+ ["git", "-C", root_abs, "ls-files", "-z", *extra_args],
106
+ capture_output=True,
107
+ )
108
+ if out.returncode != 0:
109
+ return []
110
+ return [p for p in out.stdout.decode("utf-8", "replace").split("\0") if p]
111
+
112
+
113
+ def _list_via_git(root_abs: str) -> List[str]:
114
+ tracked = _git_files(root_abs, [])
115
+ untracked = _git_files(root_abs, ["--others", "--exclude-standard"])
116
+ # Union preserving first-seen order.
117
+ return list(dict.fromkeys([*tracked, *untracked]))
118
+
119
+
120
+ def _rel_posix(root_abs: str, abs_path: str) -> str:
121
+ return os.path.relpath(abs_path, root_abs).replace(os.sep, "/")
122
+
123
+
124
+ def _list_via_walk(root_abs: str, warnings: List[str]) -> List[str]:
125
+ out: List[str] = []
126
+
127
+ def walk(dir_abs: str) -> None:
128
+ try:
129
+ entries = list(os.scandir(dir_abs))
130
+ except OSError as err:
131
+ rel = _rel_posix(root_abs, dir_abs) or "."
132
+ warnings.append(f"skipped unreadable dir {rel}: {err}")
133
+ return
134
+ for e in entries:
135
+ if e.name in DEFAULT_IGNORES:
136
+ continue
137
+ abs_path = os.path.join(dir_abs, e.name)
138
+ rel = _rel_posix(root_abs, abs_path)
139
+ if e.is_symlink():
140
+ try:
141
+ if os.path.isdir(abs_path): # follows link
142
+ warnings.append(f"skipped symlinked dir {rel}")
143
+ elif os.path.isfile(abs_path):
144
+ out.append(rel)
145
+ else:
146
+ warnings.append(f"skipped broken symlink {rel}")
147
+ except OSError:
148
+ warnings.append(f"skipped broken symlink {rel}")
149
+ elif e.is_dir(follow_symlinks=False):
150
+ walk(abs_path)
151
+ elif e.is_file(follow_symlinks=False):
152
+ out.append(rel)
153
+
154
+ walk(root_abs)
155
+ return out
156
+
157
+
158
+ def _collect_env_examples(root_abs: str) -> List[str]:
159
+ out: List[str] = []
160
+
161
+ def walk(dir_abs: str) -> None:
162
+ try:
163
+ entries = list(os.scandir(dir_abs))
164
+ except OSError:
165
+ return
166
+ for e in entries:
167
+ if e.name in DEFAULT_IGNORES or e.name in SECRET_DIRS:
168
+ continue
169
+ abs_path = os.path.join(dir_abs, e.name)
170
+ if e.is_dir(follow_symlinks=False):
171
+ walk(abs_path)
172
+ elif (e.is_file(follow_symlinks=False) or e.is_symlink()) and e.name in ENV_ALLOWLIST:
173
+ out.append(_rel_posix(root_abs, abs_path))
174
+
175
+ walk(root_abs)
176
+ return out
177
+
178
+
179
+ def zip_directory(root_abs: str) -> ZipResult:
180
+ """Zip ``root_abs`` into an in-memory archive. See module docstring."""
181
+ if not os.path.isabs(root_abs):
182
+ raise ValueError(f'path must be absolute, got "{root_abs}"')
183
+ if not os.path.exists(root_abs):
184
+ raise FileNotFoundError(f"path not found: {root_abs}")
185
+ if not os.path.isdir(root_abs):
186
+ raise NotADirectoryError(f"path is not a directory: {root_abs}")
187
+
188
+ warnings: List[str] = []
189
+
190
+ # 1) Candidate files (root-relative posix), honoring .gitignore when possible,
191
+ # plus force-included .env.example-style files.
192
+ candidates = _list_via_git(root_abs) if _is_git_repo(root_abs) else _list_via_walk(root_abs, warnings)
193
+ all_files = set(candidates)
194
+ for ex in _collect_env_examples(root_abs):
195
+ all_files.add(ex)
196
+
197
+ # 2) Strip secrets/credentials regardless of source.
198
+ kept: List[str] = []
199
+ for rel in all_files:
200
+ reason = _classify_secret(rel)
201
+ if reason:
202
+ warnings.append(
203
+ f"omitted {reason}: {rel} — pass secrets via environment_variables, not the archive"
204
+ )
205
+ else:
206
+ kept.append(rel)
207
+ kept.sort()
208
+
209
+ if not kept:
210
+ raise ValueError(f"no files to deploy from {root_abs} after applying ignore + secret filters")
211
+
212
+ # 3) Stat pass -> size guard + fingerprint (before reading any bytes).
213
+ stats: List[Tuple[str, int, int]] = [] # (rel, size, mtime_ms)
214
+ uncompressed = 0
215
+ for rel in kept:
216
+ abs_path = os.path.join(root_abs, rel)
217
+ try:
218
+ st = os.stat(abs_path) # follows symlinked files
219
+ except OSError as err:
220
+ warnings.append(f"skipped {rel}: {err}")
221
+ continue
222
+ if not os.path.isfile(abs_path):
223
+ warnings.append(f"skipped {rel}: not a regular file")
224
+ continue
225
+ uncompressed += st.st_size
226
+ if uncompressed > UNCOMPRESSED_TRIPWIRE:
227
+ raise ValueError(
228
+ f"directory too large to archive (uncompressed > "
229
+ f"{round(UNCOMPRESSED_TRIPWIRE / 1024 / 1024)} MiB). "
230
+ f"Are you running this on the wrong folder? Deploy a specific project directory."
231
+ )
232
+ stats.append((rel, st.st_size, round(st.st_mtime * 1000)))
233
+ if not stats:
234
+ raise ValueError(f"no readable files to deploy from {root_abs}")
235
+
236
+ fingerprint = json.dumps([[rel, size, mtime] for rel, size, mtime in stats])
237
+ hit = _cache.get(root_abs)
238
+ if hit and hit[0] == fingerprint:
239
+ # Reuse the cached bytes but report this call's warnings.
240
+ return replace(hit[1], warnings=warnings)
241
+
242
+ # 4) Read bytes -> entries (root-relative posix keys, no wrapper dir).
243
+ entries: List[Tuple[str, bytes]] = []
244
+ for rel, _size, _mtime in stats:
245
+ try:
246
+ with open(os.path.join(root_abs, rel), "rb") as fh:
247
+ entries.append((rel, fh.read()))
248
+ except OSError as err:
249
+ warnings.append(f"skipped {rel}: {err}")
250
+ if not entries:
251
+ raise ValueError(f"no readable files to deploy from {root_abs}")
252
+
253
+ # 5) Compress (DEFLATE level 6, posix arcnames).
254
+ buf = io.BytesIO()
255
+ with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
256
+ for rel, data in entries:
257
+ zf.writestr(rel, data)
258
+ zipped = buf.getvalue()
259
+
260
+ if len(zipped) > MAX_ZIP_BYTES:
261
+ raise ValueError(
262
+ f"zipped project is {len(zipped) / 1024 / 1024:.1f} MiB, over the "
263
+ f"{MAX_ZIP_BYTES // 1024 // 1024} MiB upload limit. "
264
+ f"Trim large files or deploy from a git repo_url instead."
265
+ )
266
+
267
+ result = ZipResult(
268
+ zip=zipped, file_count=len(entries), bytes=len(zipped), root=root_abs, warnings=warnings
269
+ )
270
+ _cache[root_abs] = (fingerprint, result)
271
+ return result
@@ -0,0 +1,122 @@
1
+ """Asynchronous :class:`AsyncPreviewsClient` — mirror of the sync client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, AsyncIterator, Mapping, Optional
6
+
7
+ import httpx
8
+
9
+ from ._config import resolve_api_key, resolve_base_url
10
+ from ._http import build_headers, process_response
11
+ from .errors import PreviewsConfigError
12
+ from .resources.accounts import AsyncAccountsResource
13
+ from .resources.detect import AsyncDetectResource
14
+ from .resources.drives import AsyncDrivesResource
15
+ from .resources.integrations import AsyncIntegrationsResource
16
+ from .resources.snapshots import AsyncSnapshotsResource
17
+ from .resources.vms import AsyncVMsResource
18
+ from .sse import Frame, ensure_stream_ok_async, parse_named_sse_async
19
+
20
+
21
+ class AsyncPreviewsClient:
22
+ """Asynchronous client for the Propie / Previews API. See
23
+ :class:`~previews.client.PreviewsClient` for config resolution semantics."""
24
+
25
+ def __init__(
26
+ self,
27
+ api_key: Optional[str] = None,
28
+ *,
29
+ base_url: Optional[str] = None,
30
+ timeout: float = 30.0,
31
+ http_client: Optional[httpx.AsyncClient] = None,
32
+ ) -> None:
33
+ key = resolve_api_key(api_key)
34
+ if not key:
35
+ raise PreviewsConfigError(
36
+ "No API key. Pass api_key=... or set TREINTA_PREVIEWS_API_KEY "
37
+ "(aliases: PROPIE_API_KEY, PREVIEWS_API_KEY)."
38
+ )
39
+ self._api_key = key
40
+ self._base_url = resolve_base_url(base_url)
41
+ self._timeout = timeout
42
+ self._owns_http = http_client is None
43
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
44
+
45
+ self.vms = AsyncVMsResource(self)
46
+ self.snapshots = AsyncSnapshotsResource(self)
47
+ self.drives = AsyncDrivesResource(self)
48
+ self.integrations = AsyncIntegrationsResource(self)
49
+ self.detect = AsyncDetectResource(self)
50
+ self.accounts = AsyncAccountsResource(self)
51
+
52
+ @property
53
+ def base_url(self) -> str:
54
+ return self._base_url
55
+
56
+ def _url(self, path: str) -> str:
57
+ return f"{self._base_url}{path}"
58
+
59
+ async def request(
60
+ self,
61
+ method: str,
62
+ path: str,
63
+ *,
64
+ json: Any = None,
65
+ params: Optional[Mapping[str, Any]] = None,
66
+ headers: Optional[Mapping[str, str]] = None,
67
+ content: Any = None,
68
+ data: Optional[Mapping[str, Any]] = None,
69
+ files: Any = None,
70
+ timeout: Optional[float] = None,
71
+ ) -> Any:
72
+ resp = await self._http.request(
73
+ method,
74
+ self._url(path),
75
+ json=json,
76
+ params=params,
77
+ headers=build_headers(self._api_key, headers),
78
+ content=content,
79
+ data=data,
80
+ files=files,
81
+ timeout=timeout if timeout is not None else self._timeout,
82
+ )
83
+ return process_response(resp.status_code, resp.reason_phrase, resp.text, resp.is_success)
84
+
85
+ async def _aiter_sse(
86
+ self,
87
+ method: str,
88
+ path: str,
89
+ *,
90
+ json: Any = None,
91
+ params: Optional[Mapping[str, Any]] = None,
92
+ files: Any = None,
93
+ data: Optional[Mapping[str, Any]] = None,
94
+ ) -> AsyncIterator[Frame]:
95
+ timeout = httpx.Timeout(self._timeout, read=None)
96
+ headers = build_headers(self._api_key, {"Accept": "text/event-stream"})
97
+ async with self._http.stream(
98
+ method,
99
+ self._url(path),
100
+ json=json,
101
+ params=params,
102
+ files=files,
103
+ data=data,
104
+ headers=headers,
105
+ timeout=timeout,
106
+ ) as resp:
107
+ await ensure_stream_ok_async(resp)
108
+ async for frame in parse_named_sse_async(resp):
109
+ yield frame
110
+
111
+ async def aclose(self) -> None:
112
+ if self._owns_http:
113
+ await self._http.aclose()
114
+
115
+ async def __aenter__(self) -> "AsyncPreviewsClient":
116
+ return self
117
+
118
+ async def __aexit__(self, *exc: Any) -> None:
119
+ await self.aclose()
120
+
121
+
122
+ __all__ = ["AsyncPreviewsClient"]