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 +88 -0
- previews/_case.py +48 -0
- previews/_config.py +45 -0
- previews/_http.py +62 -0
- previews/_version.py +3 -0
- previews/archive.py +271 -0
- previews/async_client.py +122 -0
- previews/client.py +136 -0
- previews/errors.py +36 -0
- previews/models.py +351 -0
- previews/proxy/__init__.py +27 -0
- previews/proxy/core.py +122 -0
- previews/proxy/fastapi.py +139 -0
- previews/proxy/flask.py +129 -0
- previews/py.typed +0 -0
- previews/resources/__init__.py +70 -0
- previews/resources/accounts.py +29 -0
- previews/resources/detect.py +64 -0
- previews/resources/drives.py +64 -0
- previews/resources/integrations.py +58 -0
- previews/resources/snapshots.py +86 -0
- previews/resources/vms.py +335 -0
- previews/sse.py +205 -0
- treinta_previews-0.1.0.dist-info/METADATA +241 -0
- treinta_previews-0.1.0.dist-info/RECORD +27 -0
- treinta_previews-0.1.0.dist-info/WHEEL +4 -0
- treinta_previews-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|
previews/async_client.py
ADDED
|
@@ -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"]
|