justdeploy-sdk 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.
justdeploy/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ from ._version import __version__
2
+ from .client import AsyncJustDeploy, JustDeploy
3
+ from .errors import JustDeployAuthenticationError, JustDeployConfigurationError, JustDeployError, JustDeployValidationError
4
+ from .types import (
5
+ AsyncFileDownload,
6
+ AsyncUploadBody,
7
+ Column,
8
+ ColumnDefinition,
9
+ ColumnType,
10
+ CreateTableInput,
11
+ Database,
12
+ FileDownload,
13
+ FileInfo,
14
+ FilePage,
15
+ JsonObject,
16
+ JsonPrimitive,
17
+ JsonValue,
18
+ Mail,
19
+ MailPage,
20
+ MailStatus,
21
+ QueryResult,
22
+ Storage,
23
+ StoredFile,
24
+ SyncUploadBody,
25
+ Table,
26
+ UpdateTableInput,
27
+ )
28
+
29
+ __all__ = [
30
+ "AsyncFileDownload",
31
+ "AsyncJustDeploy",
32
+ "AsyncUploadBody",
33
+ "Column",
34
+ "ColumnDefinition",
35
+ "ColumnType",
36
+ "CreateTableInput",
37
+ "Database",
38
+ "FileDownload",
39
+ "FileInfo",
40
+ "FilePage",
41
+ "JsonObject",
42
+ "JsonPrimitive",
43
+ "JsonValue",
44
+ "JustDeploy",
45
+ "JustDeployAuthenticationError",
46
+ "JustDeployConfigurationError",
47
+ "JustDeployError",
48
+ "JustDeployValidationError",
49
+ "Mail",
50
+ "MailPage",
51
+ "MailStatus",
52
+ "QueryResult",
53
+ "Storage",
54
+ "StoredFile",
55
+ "SyncUploadBody",
56
+ "Table",
57
+ "UpdateTableInput",
58
+ "__version__",
59
+ ]
justdeploy/_auth.py ADDED
@@ -0,0 +1,361 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ import os
7
+ import re
8
+ import stat
9
+ import threading
10
+ import time
11
+ from collections.abc import Callable, Mapping
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Final
16
+ from urllib.parse import urlsplit
17
+
18
+ import httpx
19
+ from cryptography.exceptions import UnsupportedAlgorithm
20
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
21
+ from cryptography.hazmat.primitives.serialization import load_der_private_key
22
+
23
+ from ._version import __version__
24
+ from .errors import JustDeployAuthenticationError, JustDeployConfigurationError
25
+
26
+ DEFAULT_API_ORIGIN: Final = "https://api.justdeploy.net"
27
+ DEFAULT_IDENTITY_PATH: Final = Path("/opt/justdeploy/identity.json")
28
+ REFRESH_WINDOW_SECONDS: Final = 3 * 60
29
+ AUTH_TIMEOUT_SECONDS: Final = 10.0
30
+ MAX_IDENTITY_BYTES: Final = 16 * 1024
31
+ SDK_HEADER: Final = f"python/{__version__}"
32
+ BUILD_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
33
+ PLATFORM_ID = re.compile(r"^[a-z0-9]{16}$")
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class IdentityDocument:
38
+ protocol_version: int
39
+ build_id: str
40
+ private_key: str
41
+ api_origin: str
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class AuthSession:
46
+ token: str
47
+ organization_id: str
48
+ expires_at: float
49
+ api_origin: str
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ResolvedAuthentication:
54
+ api_origin: str
55
+ credentials: tuple[str, str] | None
56
+ identity: IdentityDocument | None
57
+
58
+
59
+ def _validate_api_origin(value: object) -> str:
60
+ if not isinstance(value, str):
61
+ raise JustDeployConfigurationError("The JustDeploy API URL in the deployment identity is invalid.")
62
+ try:
63
+ parsed = urlsplit(value)
64
+ _ = parsed.port
65
+ except ValueError as error:
66
+ raise JustDeployConfigurationError("The JustDeploy API URL in the deployment identity is invalid.") from error
67
+ if (
68
+ parsed.scheme != "https"
69
+ or not parsed.hostname
70
+ or parsed.username is not None
71
+ or parsed.password is not None
72
+ or parsed.path not in ("", "/")
73
+ or parsed.query
74
+ or parsed.fragment
75
+ ):
76
+ raise JustDeployConfigurationError("The JustDeploy API URL in the deployment identity must be an HTTPS origin.")
77
+ return f"https://{parsed.netloc.lower()}"
78
+
79
+
80
+ def _read_identity(path: Path) -> dict[str, Any] | None:
81
+ try:
82
+ initial_metadata = os.lstat(path)
83
+ except FileNotFoundError:
84
+ return None
85
+ except OSError as error:
86
+ raise JustDeployConfigurationError("The JustDeploy deployment identity could not be inspected.") from error
87
+ if not stat.S_ISREG(initial_metadata.st_mode) or stat.S_ISLNK(initial_metadata.st_mode):
88
+ raise JustDeployConfigurationError("The JustDeploy deployment identity must be a regular file, not a link or directory.")
89
+
90
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
91
+ try:
92
+ descriptor = os.open(path, flags)
93
+ except FileNotFoundError:
94
+ return None
95
+ except OSError as error:
96
+ raise JustDeployConfigurationError("The JustDeploy deployment identity could not be opened safely.") from error
97
+
98
+ try:
99
+ metadata = os.fstat(descriptor)
100
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_dev != initial_metadata.st_dev or metadata.st_ino != initial_metadata.st_ino:
101
+ raise JustDeployConfigurationError("The JustDeploy deployment identity must be a regular file, not a link or directory.")
102
+ permissions = stat.S_IMODE(metadata.st_mode)
103
+ if permissions & 0o333 or not permissions & 0o444:
104
+ raise JustDeployConfigurationError(
105
+ "The JustDeploy deployment identity must be readable and have no write or execute permissions."
106
+ )
107
+ if metadata.st_size <= 0 or metadata.st_size > MAX_IDENTITY_BYTES:
108
+ raise JustDeployConfigurationError("The JustDeploy deployment identity has an invalid size.")
109
+ chunks = bytearray()
110
+ while len(chunks) <= metadata.st_size:
111
+ chunk = os.read(descriptor, metadata.st_size + 1 - len(chunks))
112
+ if not chunk:
113
+ break
114
+ chunks.extend(chunk)
115
+ if len(chunks) != metadata.st_size:
116
+ raise JustDeployConfigurationError("The JustDeploy deployment identity changed while it was being read.")
117
+ raw = bytes(chunks)
118
+ finally:
119
+ os.close(descriptor)
120
+
121
+ try:
122
+ value = json.loads(raw)
123
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
124
+ raise JustDeployConfigurationError("The JustDeploy deployment identity is not valid JSON.") from error
125
+ if not isinstance(value, dict):
126
+ raise JustDeployConfigurationError("The JustDeploy deployment identity is invalid.")
127
+ return value
128
+
129
+
130
+ def _parse_identity(path: Path, *, require_build_key: bool) -> IdentityDocument | None:
131
+ value = _read_identity(path)
132
+ if value is None:
133
+ return None
134
+ protocol_version = value.get("protocolVersion")
135
+ if type(protocol_version) is not int or protocol_version != 1:
136
+ raise JustDeployConfigurationError("The JustDeploy deployment identity protocol is not supported by this SDK.")
137
+ build_id = value.get("buildId", "")
138
+ private_key = value.get("privateKey", "")
139
+ if require_build_key and (
140
+ not isinstance(build_id, str) or BUILD_UUID.fullmatch(build_id) is None or not isinstance(private_key, str) or not private_key
141
+ ):
142
+ raise JustDeployConfigurationError("The JustDeploy deployment identity is missing a valid build key.")
143
+ return IdentityDocument(protocol_version, str(build_id), str(private_key), _validate_api_origin(value.get("apiBaseUrl")))
144
+
145
+
146
+ def _resolve_authentication(env: Mapping[str, str], identity_path: Path) -> ResolvedAuthentication:
147
+ access = env.get("JUSTDEPLOY_ACCESS_KEY")
148
+ secret = env.get("JUSTDEPLOY_SECRET_KEY")
149
+ has_access = access is not None
150
+ has_secret = secret is not None
151
+ if has_access != has_secret or (has_access and (not access or not secret)):
152
+ raise JustDeployAuthenticationError("Set both JUSTDEPLOY_ACCESS_KEY and JUSTDEPLOY_SECRET_KEY to non-empty values.")
153
+
154
+ identity = _parse_identity(identity_path, require_build_key=not has_access)
155
+ api_origin = identity.api_origin if identity else DEFAULT_API_ORIGIN
156
+ if has_access and has_secret:
157
+ assert access is not None and secret is not None
158
+ return ResolvedAuthentication(api_origin, (access, secret), identity)
159
+ if identity is None:
160
+ raise JustDeployAuthenticationError(
161
+ "JustDeploy authentication is not configured. Set JUSTDEPLOY_ACCESS_KEY and JUSTDEPLOY_SECRET_KEY for local development; "
162
+ "deployed JustDeploy applications receive an identity automatically."
163
+ )
164
+ return ResolvedAuthentication(api_origin, None, identity)
165
+
166
+
167
+ def _build_request(resolved: ResolvedAuthentication, now: float) -> tuple[str, dict[str, str], dict[str, object]]:
168
+ common_headers = {"accept": "application/json", "content-type": "application/json", "x-justdeploy-sdk": SDK_HEADER}
169
+ if resolved.credentials:
170
+ access, secret = resolved.credentials
171
+ return (
172
+ f"{resolved.api_origin}/auth/credential",
173
+ {**common_headers, "authorization": f"Bearer {access}:{secret}"},
174
+ {},
175
+ )
176
+
177
+ identity = resolved.identity
178
+ if identity is None: # pragma: no cover - guarded by _resolve_authentication
179
+ raise JustDeployAuthenticationError("JustDeploy authentication is not configured.")
180
+ issued_at = int(now)
181
+ signing_input = f"justdeploy-build-auth-v1\n{resolved.api_origin}\nPOST\n/auth/build\n{identity.build_id}\n{issued_at}".encode()
182
+ try:
183
+ encoded_key = base64.b64decode(identity.private_key, validate=True)
184
+ private_key = load_der_private_key(encoded_key, password=None)
185
+ if not isinstance(private_key, Ed25519PrivateKey):
186
+ raise ValueError
187
+ signature = base64.urlsafe_b64encode(private_key.sign(signing_input)).rstrip(b"=").decode("ascii")
188
+ except (TypeError, ValueError, UnsupportedAlgorithm):
189
+ raise JustDeployConfigurationError("The JustDeploy deployment identity contains an invalid Ed25519 private key.") from None
190
+ return (
191
+ f"{resolved.api_origin}/auth/build",
192
+ {**common_headers, "x-justdeploy-build-signature": signature},
193
+ {"buildId": identity.build_id, "issuedAt": issued_at},
194
+ )
195
+
196
+
197
+ def _session_from_response(response: httpx.Response, api_origin: str, now: float) -> AuthSession:
198
+ if not response.is_success:
199
+ message = "JustDeploy authentication was rejected."
200
+ details: dict[str, Any] = {}
201
+ try:
202
+ payload = response.json()
203
+ if isinstance(payload, dict):
204
+ details = payload
205
+ if isinstance(payload.get("message"), str) and payload["message"]:
206
+ message = payload["message"]
207
+ except (ValueError, UnicodeDecodeError):
208
+ pass
209
+ retry_after = details.get("retryAfter")
210
+ request_id = details.get("requestId")
211
+ raise JustDeployAuthenticationError(
212
+ message,
213
+ status=response.status_code,
214
+ retry_after=retry_after if isinstance(retry_after, int) and not isinstance(retry_after, bool) else None,
215
+ request_id=request_id if isinstance(request_id, str) else response.headers.get("x-request-id"),
216
+ details=details,
217
+ )
218
+ try:
219
+ payload = response.json()
220
+ token = payload["token"]
221
+ organization_id = payload["organizationId"]
222
+ raw_expiry = payload["expiresAt"]
223
+ if not isinstance(raw_expiry, str):
224
+ raise ValueError
225
+ parsed_expiry = datetime.fromisoformat(raw_expiry.replace("Z", "+00:00"))
226
+ if parsed_expiry.tzinfo is None or parsed_expiry.utcoffset() is None:
227
+ raise ValueError
228
+ expires_at = parsed_expiry.timestamp()
229
+ except (KeyError, TypeError, ValueError, UnicodeDecodeError):
230
+ raise JustDeployAuthenticationError(
231
+ "JustDeploy returned an invalid authentication response.",
232
+ status=response.status_code,
233
+ request_id=response.headers.get("x-request-id"),
234
+ ) from None
235
+ if (
236
+ not isinstance(token, str)
237
+ or not token
238
+ or not isinstance(organization_id, str)
239
+ or PLATFORM_ID.fullmatch(organization_id) is None
240
+ or expires_at <= now
241
+ ):
242
+ raise JustDeployAuthenticationError(
243
+ "JustDeploy returned an invalid authentication response.",
244
+ status=response.status_code,
245
+ request_id=response.headers.get("x-request-id"),
246
+ )
247
+ return AuthSession(token, organization_id, expires_at, api_origin)
248
+
249
+
250
+ class SyncAuthManager:
251
+ def __init__(
252
+ self,
253
+ client: httpx.Client,
254
+ *,
255
+ env: Mapping[str, str] | None = None,
256
+ identity_path: Path = DEFAULT_IDENTITY_PATH,
257
+ clock: Callable[[], float] = time.time,
258
+ ) -> None:
259
+ self._client = client
260
+ self._env = dict(os.environ if env is None else env)
261
+ self._identity_path = identity_path
262
+ self._clock = clock
263
+ self._session: AuthSession | None = None
264
+ self._credential_authentication: ResolvedAuthentication | None = None
265
+ self._lock = threading.Lock()
266
+
267
+ def _valid_session(self) -> AuthSession | None:
268
+ if self._session and self._session.expires_at - self._clock() > REFRESH_WINDOW_SECONDS:
269
+ return self._session
270
+ return None
271
+
272
+ def get_session(self) -> AuthSession:
273
+ if session := self._valid_session():
274
+ return session
275
+ with self._lock:
276
+ if session := self._valid_session():
277
+ return session
278
+ self._session = self._exchange()
279
+ return self._session
280
+
281
+ def refresh_after_unauthorized(self, stale_token: str) -> AuthSession:
282
+ with self._lock:
283
+ if self._session and self._session.token != stale_token and (session := self._valid_session()):
284
+ return session
285
+ self._session = self._exchange()
286
+ return self._session
287
+
288
+ def _exchange(self) -> AuthSession:
289
+ resolved = self._resolve()
290
+ url, headers, body = _build_request(resolved, self._clock())
291
+ try:
292
+ response = self._client.post(url, headers=headers, json=body, timeout=AUTH_TIMEOUT_SECONDS, follow_redirects=False)
293
+ except httpx.HTTPError:
294
+ raise JustDeployAuthenticationError("JustDeploy authentication failed before the server returned a response.") from None
295
+ return _session_from_response(response, resolved.api_origin, self._clock())
296
+
297
+ def _resolve(self) -> ResolvedAuthentication:
298
+ if self._credential_authentication:
299
+ return self._credential_authentication
300
+ resolved = _resolve_authentication(self._env, self._identity_path)
301
+ if resolved.credentials:
302
+ self._credential_authentication = ResolvedAuthentication(resolved.api_origin, resolved.credentials, None)
303
+ return self._credential_authentication
304
+ return resolved
305
+
306
+
307
+ class AsyncAuthManager:
308
+ def __init__(
309
+ self,
310
+ client: httpx.AsyncClient,
311
+ *,
312
+ env: Mapping[str, str] | None = None,
313
+ identity_path: Path = DEFAULT_IDENTITY_PATH,
314
+ clock: Callable[[], float] = time.time,
315
+ ) -> None:
316
+ self._client = client
317
+ self._env = dict(os.environ if env is None else env)
318
+ self._identity_path = identity_path
319
+ self._clock = clock
320
+ self._session: AuthSession | None = None
321
+ self._credential_authentication: ResolvedAuthentication | None = None
322
+ self._lock = asyncio.Lock()
323
+
324
+ def _valid_session(self) -> AuthSession | None:
325
+ if self._session and self._session.expires_at - self._clock() > REFRESH_WINDOW_SECONDS:
326
+ return self._session
327
+ return None
328
+
329
+ async def get_session(self) -> AuthSession:
330
+ if session := self._valid_session():
331
+ return session
332
+ async with self._lock:
333
+ if session := self._valid_session():
334
+ return session
335
+ self._session = await self._exchange()
336
+ return self._session
337
+
338
+ async def refresh_after_unauthorized(self, stale_token: str) -> AuthSession:
339
+ async with self._lock:
340
+ if self._session and self._session.token != stale_token and (session := self._valid_session()):
341
+ return session
342
+ self._session = await self._exchange()
343
+ return self._session
344
+
345
+ async def _exchange(self) -> AuthSession:
346
+ resolved = self._resolve()
347
+ url, headers, body = _build_request(resolved, self._clock())
348
+ try:
349
+ response = await self._client.post(url, headers=headers, json=body, timeout=AUTH_TIMEOUT_SECONDS, follow_redirects=False)
350
+ except httpx.HTTPError:
351
+ raise JustDeployAuthenticationError("JustDeploy authentication failed before the server returned a response.") from None
352
+ return _session_from_response(response, resolved.api_origin, self._clock())
353
+
354
+ def _resolve(self) -> ResolvedAuthentication:
355
+ if self._credential_authentication:
356
+ return self._credential_authentication
357
+ resolved = _resolve_authentication(self._env, self._identity_path)
358
+ if resolved.credentials:
359
+ self._credential_authentication = ResolvedAuthentication(resolved.api_origin, resolved.credentials, None)
360
+ return self._credential_authentication
361
+ return resolved
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from typing import Any, Literal, cast
6
+ from urllib.parse import quote, urlsplit
7
+
8
+ import httpx
9
+
10
+ from ._auth import SDK_HEADER, AsyncAuthManager, AuthSession, SyncAuthManager
11
+ from .errors import JustDeployError, JustDeployValidationError
12
+ from .types import AsyncUploadBody, SyncUploadBody
13
+
14
+ Method = Literal["GET", "POST", "PUT", "DELETE"]
15
+ API_TIMEOUT_SECONDS = 30.0
16
+ TRANSFER_FORBIDDEN_HEADERS = ("authorization", "x-justdeploy-sdk", "cookie")
17
+
18
+
19
+ def _api_error(response: httpx.Response, payload: object) -> JustDeployError:
20
+ details = payload if isinstance(payload, dict) else {}
21
+ message = details.get("message")
22
+ if not isinstance(message, str) or not message:
23
+ message = f"JustDeploy request failed with status {response.status_code}."
24
+ retry_after = details.get("retryAfter")
25
+ request_id = details.get("requestId")
26
+ if not isinstance(request_id, str):
27
+ request_id = response.headers.get("x-request-id")
28
+ return JustDeployError(
29
+ message,
30
+ status=response.status_code,
31
+ retry_after=retry_after if isinstance(retry_after, int) and not isinstance(retry_after, bool) else None,
32
+ request_id=request_id,
33
+ details=cast(Mapping[str, Any], details),
34
+ )
35
+
36
+
37
+ def _payload(response: httpx.Response) -> object:
38
+ if not response.content:
39
+ return None
40
+ try:
41
+ return response.json()
42
+ except (ValueError, UnicodeDecodeError):
43
+ raise JustDeployError("JustDeploy returned a response that was not valid JSON.", status=response.status_code) from None
44
+
45
+
46
+ def _json_content(value: object) -> bytes:
47
+ try:
48
+ return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode()
49
+ except (TypeError, ValueError, UnicodeEncodeError):
50
+ raise JustDeployValidationError("The request contains a value that cannot be encoded as JSON.") from None
51
+
52
+
53
+ def _validate_presigned_url(url: str) -> str:
54
+ try:
55
+ parsed = urlsplit(url)
56
+ _ = parsed.port
57
+ except (TypeError, ValueError):
58
+ raise JustDeployError("JustDeploy returned an invalid file URL.") from None
59
+ if parsed.scheme != "https" or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.fragment:
60
+ raise JustDeployError("JustDeploy returned an invalid file URL.")
61
+ return url
62
+
63
+
64
+ def _api_url(session: AuthSession, path: str) -> str:
65
+ if not path.startswith("/") or path.startswith("//") or "://" in path or "\\" in path:
66
+ raise RuntimeError("Invalid internal JustDeploy API path.")
67
+ organization_id = quote(session.organization_id, safe="")
68
+ return f"{session.api_origin}/organizations/{organization_id}{path}"
69
+
70
+
71
+ class SyncTransport:
72
+ def __init__(self, client: httpx.Client, auth: SyncAuthManager) -> None:
73
+ self.client = client
74
+ self.auth = auth
75
+
76
+ def organization_request(
77
+ self,
78
+ method: Method,
79
+ path: str,
80
+ *,
81
+ json_body: object = None,
82
+ headers: Mapping[str, str] | None = None,
83
+ ) -> object:
84
+ session = self.auth.get_session()
85
+ return self._send(method, path, session, json_body=json_body, headers=headers, replayed=False)
86
+
87
+ def _send(
88
+ self,
89
+ method: Method,
90
+ path: str,
91
+ session: AuthSession,
92
+ *,
93
+ json_body: object,
94
+ headers: Mapping[str, str] | None,
95
+ replayed: bool,
96
+ ) -> object:
97
+ request_headers = {"accept": "application/json", "authorization": f"Bearer {session.token}", "x-justdeploy-sdk": SDK_HEADER}
98
+ for name, value in (headers or {}).items():
99
+ if name.lower() in {"authorization", "host", "x-justdeploy-sdk"}:
100
+ raise RuntimeError(f"The internal header {name} cannot be overridden.")
101
+ request_headers[name.lower()] = value
102
+ content = _json_content(json_body) if json_body is not None else None
103
+ if content is not None:
104
+ request_headers["content-type"] = "application/json"
105
+ try:
106
+ url = _api_url(session, path)
107
+ response = self.client.request(
108
+ method,
109
+ url,
110
+ headers=request_headers,
111
+ content=content,
112
+ timeout=API_TIMEOUT_SECONDS,
113
+ follow_redirects=False,
114
+ )
115
+ except httpx.HTTPError:
116
+ raise JustDeployError("The JustDeploy request failed before the server returned a response.") from None
117
+ if response.status_code == 401 and method == "GET" and not replayed:
118
+ response.close()
119
+ refreshed = self.auth.refresh_after_unauthorized(session.token)
120
+ return self._send(method, path, refreshed, json_body=json_body, headers=headers, replayed=True)
121
+ payload = _payload(response)
122
+ if not response.is_success:
123
+ raise _api_error(response, payload)
124
+ return payload
125
+
126
+ def presigned_upload(self, url: str, *, mime: str, data: SyncUploadBody, size: int) -> httpx.Response:
127
+ validated_url = _validate_presigned_url(url)
128
+ try:
129
+ request = self.client.build_request(
130
+ "PUT",
131
+ validated_url,
132
+ headers={"content-type": mime, "content-length": str(size)},
133
+ content=data,
134
+ )
135
+ for name in TRANSFER_FORBIDDEN_HEADERS:
136
+ request.headers.pop(name, None)
137
+ return self.client.send(request, follow_redirects=False)
138
+ except Exception:
139
+ raise JustDeployError("The file transfer failed before the server returned a response.") from None
140
+
141
+ def presigned_download(self, url: str) -> httpx.Response:
142
+ validated_url = _validate_presigned_url(url)
143
+ try:
144
+ request = self.client.build_request("GET", validated_url)
145
+ for name in TRANSFER_FORBIDDEN_HEADERS:
146
+ request.headers.pop(name, None)
147
+ return self.client.send(request, stream=True, follow_redirects=False)
148
+ except Exception:
149
+ raise JustDeployError("The file transfer failed before the server returned a response.") from None
150
+
151
+
152
+ class AsyncTransport:
153
+ def __init__(self, client: httpx.AsyncClient, auth: AsyncAuthManager) -> None:
154
+ self.client = client
155
+ self.auth = auth
156
+
157
+ async def organization_request(
158
+ self,
159
+ method: Method,
160
+ path: str,
161
+ *,
162
+ json_body: object = None,
163
+ headers: Mapping[str, str] | None = None,
164
+ ) -> object:
165
+ session = await self.auth.get_session()
166
+ return await self._send(method, path, session, json_body=json_body, headers=headers, replayed=False)
167
+
168
+ async def _send(
169
+ self,
170
+ method: Method,
171
+ path: str,
172
+ session: AuthSession,
173
+ *,
174
+ json_body: object,
175
+ headers: Mapping[str, str] | None,
176
+ replayed: bool,
177
+ ) -> object:
178
+ request_headers = {"accept": "application/json", "authorization": f"Bearer {session.token}", "x-justdeploy-sdk": SDK_HEADER}
179
+ for name, value in (headers or {}).items():
180
+ if name.lower() in {"authorization", "host", "x-justdeploy-sdk"}:
181
+ raise RuntimeError(f"The internal header {name} cannot be overridden.")
182
+ request_headers[name.lower()] = value
183
+ content = _json_content(json_body) if json_body is not None else None
184
+ if content is not None:
185
+ request_headers["content-type"] = "application/json"
186
+ try:
187
+ url = _api_url(session, path)
188
+ response = await self.client.request(
189
+ method,
190
+ url,
191
+ headers=request_headers,
192
+ content=content,
193
+ timeout=API_TIMEOUT_SECONDS,
194
+ follow_redirects=False,
195
+ )
196
+ except httpx.HTTPError:
197
+ raise JustDeployError("The JustDeploy request failed before the server returned a response.") from None
198
+ if response.status_code == 401 and method == "GET" and not replayed:
199
+ await response.aclose()
200
+ refreshed = await self.auth.refresh_after_unauthorized(session.token)
201
+ return await self._send(method, path, refreshed, json_body=json_body, headers=headers, replayed=True)
202
+ payload = _payload(response)
203
+ if not response.is_success:
204
+ raise _api_error(response, payload)
205
+ return payload
206
+
207
+ async def presigned_upload(self, url: str, *, mime: str, data: AsyncUploadBody, size: int) -> httpx.Response:
208
+ validated_url = _validate_presigned_url(url)
209
+ try:
210
+ request = self.client.build_request(
211
+ "PUT",
212
+ validated_url,
213
+ headers={"content-type": mime, "content-length": str(size)},
214
+ content=data,
215
+ )
216
+ for name in TRANSFER_FORBIDDEN_HEADERS:
217
+ request.headers.pop(name, None)
218
+ return await self.client.send(request, follow_redirects=False)
219
+ except Exception:
220
+ raise JustDeployError("The file transfer failed before the server returned a response.") from None
221
+
222
+ async def presigned_download(self, url: str) -> httpx.Response:
223
+ validated_url = _validate_presigned_url(url)
224
+ try:
225
+ request = self.client.build_request("GET", validated_url)
226
+ for name in TRANSFER_FORBIDDEN_HEADERS:
227
+ request.headers.pop(name, None)
228
+ return await self.client.send(request, stream=True, follow_redirects=False)
229
+ except Exception:
230
+ raise JustDeployError("The file transfer failed before the server returned a response.") from None
@@ -0,0 +1,22 @@
1
+ from urllib.parse import quote, urlencode
2
+
3
+ from .errors import JustDeployValidationError
4
+
5
+
6
+ def path_segment(value: str, label: str) -> str:
7
+ if not isinstance(value, str) or not value:
8
+ raise JustDeployValidationError(f"{label} must be a non-empty string.")
9
+ return quote(value, safe="")
10
+
11
+
12
+ def page_query(*, limit: int | None, cursor: int | None, max_limit: int) -> str:
13
+ values: dict[str, str] = {}
14
+ if limit is not None:
15
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= max_limit:
16
+ raise JustDeployValidationError(f"limit must be an integer between 1 and {max_limit}.")
17
+ values["limit"] = str(limit)
18
+ if cursor is not None:
19
+ if isinstance(cursor, bool) or not isinstance(cursor, int) or cursor <= 0:
20
+ raise JustDeployValidationError("cursor must be a positive integer.")
21
+ values["cursor"] = str(cursor)
22
+ return f"?{urlencode(values)}" if values else ""
justdeploy/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"