appliku 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.
- appliku/__init__.py +108 -0
- appliku/_auth.py +167 -0
- appliku/_client.py +142 -0
- appliku/_exceptions.py +84 -0
- appliku/_main.py +122 -0
- appliku/_models.py +356 -0
- appliku/cli/__init__.py +93 -0
- appliku/cli/apps.py +217 -0
- appliku/cli/auth.py +126 -0
- appliku/cli/clusters.py +89 -0
- appliku/cli/cron_jobs.py +90 -0
- appliku/cli/datastores.py +117 -0
- appliku/cli/deployments.py +83 -0
- appliku/cli/domains.py +53 -0
- appliku/cli/invites.py +85 -0
- appliku/cli/migrations.py +52 -0
- appliku/cli/servers.py +182 -0
- appliku/cli/teams.py +78 -0
- appliku/cli/volumes.py +82 -0
- appliku/resources/__init__.py +0 -0
- appliku/resources/apps.py +166 -0
- appliku/resources/clusters.py +56 -0
- appliku/resources/cron_jobs.py +51 -0
- appliku/resources/datastores.py +62 -0
- appliku/resources/deployments.py +84 -0
- appliku/resources/domains.py +42 -0
- appliku/resources/invites.py +46 -0
- appliku/resources/migrations.py +45 -0
- appliku/resources/servers.py +59 -0
- appliku/resources/teams.py +41 -0
- appliku/resources/volumes.py +53 -0
- appliku-0.1.0.dist-info/METADATA +277 -0
- appliku-0.1.0.dist-info/RECORD +36 -0
- appliku-0.1.0.dist-info/WHEEL +4 -0
- appliku-0.1.0.dist-info/entry_points.txt +2 -0
- appliku-0.1.0.dist-info/licenses/LICENSE +21 -0
appliku/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Appliku — Python CLI and SDK for the Appliku platform."""
|
|
2
|
+
|
|
3
|
+
from appliku._exceptions import (
|
|
4
|
+
AplikuError,
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
AuthorizationError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
ServerError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
from appliku._main import Appliku
|
|
13
|
+
from appliku._models import (
|
|
14
|
+
AdvancedLogsResponse,
|
|
15
|
+
App,
|
|
16
|
+
AppCreate,
|
|
17
|
+
AppLogsRequest,
|
|
18
|
+
AppUpdate,
|
|
19
|
+
Cluster,
|
|
20
|
+
ClusterCreate,
|
|
21
|
+
ConfigVarsResponse,
|
|
22
|
+
CronJob,
|
|
23
|
+
CronJobCreate,
|
|
24
|
+
CronJobUpdate,
|
|
25
|
+
Datastore,
|
|
26
|
+
DatastoreCreate,
|
|
27
|
+
DatastoreLogsResponse,
|
|
28
|
+
Deployment,
|
|
29
|
+
Domain,
|
|
30
|
+
DomainCreate,
|
|
31
|
+
Invite,
|
|
32
|
+
InviteCreate,
|
|
33
|
+
LogEntry,
|
|
34
|
+
LogsRequestResponse,
|
|
35
|
+
Migration,
|
|
36
|
+
MigrationRun,
|
|
37
|
+
NginxLogsRequest,
|
|
38
|
+
NginxLogsResponse,
|
|
39
|
+
PaginatedResponse,
|
|
40
|
+
Server,
|
|
41
|
+
ServerCreateCustom,
|
|
42
|
+
ServerCreateDO,
|
|
43
|
+
ServerCreateEC2,
|
|
44
|
+
ServiceLogsResponse,
|
|
45
|
+
Team,
|
|
46
|
+
TeamCreate,
|
|
47
|
+
TeamUpdate,
|
|
48
|
+
UserInfo,
|
|
49
|
+
Volume,
|
|
50
|
+
VolumeCreate,
|
|
51
|
+
VolumeUpdate,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__version__ = "0.1.0"
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
# Main client
|
|
58
|
+
"Appliku",
|
|
59
|
+
# Exceptions
|
|
60
|
+
"AplikuError",
|
|
61
|
+
"AuthenticationError",
|
|
62
|
+
"AuthorizationError",
|
|
63
|
+
"NotFoundError",
|
|
64
|
+
"RateLimitError",
|
|
65
|
+
"ServerError",
|
|
66
|
+
"ValidationError",
|
|
67
|
+
# Models — Resources
|
|
68
|
+
"Team",
|
|
69
|
+
"TeamCreate",
|
|
70
|
+
"TeamUpdate",
|
|
71
|
+
"App",
|
|
72
|
+
"AppCreate",
|
|
73
|
+
"AppUpdate",
|
|
74
|
+
"ConfigVarsResponse",
|
|
75
|
+
"Deployment",
|
|
76
|
+
"LogEntry",
|
|
77
|
+
"Datastore",
|
|
78
|
+
"DatastoreCreate",
|
|
79
|
+
"Domain",
|
|
80
|
+
"DomainCreate",
|
|
81
|
+
"Volume",
|
|
82
|
+
"VolumeCreate",
|
|
83
|
+
"VolumeUpdate",
|
|
84
|
+
"CronJob",
|
|
85
|
+
"CronJobCreate",
|
|
86
|
+
"CronJobUpdate",
|
|
87
|
+
"Cluster",
|
|
88
|
+
"ClusterCreate",
|
|
89
|
+
"Server",
|
|
90
|
+
"ServerCreateDO",
|
|
91
|
+
"ServerCreateEC2",
|
|
92
|
+
"ServerCreateCustom",
|
|
93
|
+
"Invite",
|
|
94
|
+
"InviteCreate",
|
|
95
|
+
"Migration",
|
|
96
|
+
"MigrationRun",
|
|
97
|
+
# Models — Logs
|
|
98
|
+
"AdvancedLogsResponse",
|
|
99
|
+
"AppLogsRequest",
|
|
100
|
+
"DatastoreLogsResponse",
|
|
101
|
+
"LogsRequestResponse",
|
|
102
|
+
"NginxLogsRequest",
|
|
103
|
+
"NginxLogsResponse",
|
|
104
|
+
"ServiceLogsResponse",
|
|
105
|
+
# Models — Common
|
|
106
|
+
"PaginatedResponse",
|
|
107
|
+
"UserInfo",
|
|
108
|
+
]
|
appliku/_auth.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Token resolution and config persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import tomli_w
|
|
15
|
+
|
|
16
|
+
CONFIG_PATH = Path.home() / ".config" / "appliku" / "config.toml"
|
|
17
|
+
API_BASE_URL = "https://api.appliku.com"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
21
|
+
"""Load a TOML file, compatible with Python 3.10+."""
|
|
22
|
+
if sys.version_info >= (3, 11):
|
|
23
|
+
import tomllib
|
|
24
|
+
|
|
25
|
+
with open(path, "rb") as f:
|
|
26
|
+
return tomllib.load(f)
|
|
27
|
+
else:
|
|
28
|
+
import tomli # type: ignore[import-not-found]
|
|
29
|
+
|
|
30
|
+
with open(path, "rb") as f:
|
|
31
|
+
return tomli.load(f) # type: ignore[no-any-return]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_token(explicit_token: str | None = None) -> str | None:
|
|
35
|
+
"""Priority: explicit param > APPLIKU_TOKEN env > config file."""
|
|
36
|
+
if explicit_token:
|
|
37
|
+
return explicit_token
|
|
38
|
+
env = os.environ.get("APPLIKU_TOKEN")
|
|
39
|
+
if env:
|
|
40
|
+
return env
|
|
41
|
+
return _read_config_token()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_config_token() -> str | None:
|
|
45
|
+
"""Read token from ~/.config/appliku/config.toml."""
|
|
46
|
+
if not CONFIG_PATH.exists():
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
data = _load_toml(CONFIG_PATH)
|
|
50
|
+
auth_section = data.get("auth", {})
|
|
51
|
+
token = auth_section.get("token") if isinstance(auth_section, dict) else None
|
|
52
|
+
return str(token) if token is not None else None
|
|
53
|
+
except Exception:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_token(token: str) -> None:
|
|
58
|
+
"""Persist token to config file."""
|
|
59
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
data: dict[str, Any] = {"auth": {"token": token}}
|
|
61
|
+
with open(CONFIG_PATH, "wb") as f:
|
|
62
|
+
tomli_w.dump(data, f)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DeviceAuthFlow:
|
|
66
|
+
"""Handles the browser-based device authorization flow."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, base_url: str | None = None) -> None:
|
|
69
|
+
self.base_url = base_url or API_BASE_URL
|
|
70
|
+
self.device_code: str | None = None
|
|
71
|
+
self.user_code: str | None = None
|
|
72
|
+
self.verification_url: str | None = None
|
|
73
|
+
self.verification_url_complete: str | None = None
|
|
74
|
+
self.expires_in: int = 0
|
|
75
|
+
self.interval: int = 5
|
|
76
|
+
self._client = httpx.Client(base_url=self.base_url, timeout=30.0)
|
|
77
|
+
|
|
78
|
+
def initiate(self) -> None:
|
|
79
|
+
"""Start the device authorization flow."""
|
|
80
|
+
response = self._client.post(
|
|
81
|
+
"/api/cli/auth/initiate/",
|
|
82
|
+
json={"client_name": "Appliku CLI"},
|
|
83
|
+
)
|
|
84
|
+
response.raise_for_status()
|
|
85
|
+
data = response.json()
|
|
86
|
+
|
|
87
|
+
self.device_code = data["device_code"]
|
|
88
|
+
self.user_code = data["user_code"]
|
|
89
|
+
self.verification_url = data["verification_url"]
|
|
90
|
+
self.verification_url_complete = data["verification_url_complete"]
|
|
91
|
+
self.expires_in = data["expires_in"]
|
|
92
|
+
self.interval = data.get("interval", 5)
|
|
93
|
+
|
|
94
|
+
def open_browser(self) -> bool:
|
|
95
|
+
"""Attempt to open the browser. Returns True if successful."""
|
|
96
|
+
if self.verification_url_complete:
|
|
97
|
+
try:
|
|
98
|
+
webbrowser.open(self.verification_url_complete)
|
|
99
|
+
return True
|
|
100
|
+
except Exception:
|
|
101
|
+
return False
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def poll(self) -> tuple[str, str | None]:
|
|
105
|
+
"""
|
|
106
|
+
Poll for authorization status.
|
|
107
|
+
Returns (status, api_token).
|
|
108
|
+
Status: 'pending', 'authorized', 'denied', 'expired'
|
|
109
|
+
"""
|
|
110
|
+
if not self.device_code:
|
|
111
|
+
raise RuntimeError("Must call initiate() first")
|
|
112
|
+
|
|
113
|
+
response = self._client.post(
|
|
114
|
+
"/api/cli/auth/poll/",
|
|
115
|
+
json={"device_code": self.device_code},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if response.status_code == 202:
|
|
119
|
+
return ("pending", None)
|
|
120
|
+
elif response.status_code == 200:
|
|
121
|
+
data = response.json()
|
|
122
|
+
return (data["status"], data.get("api_token"))
|
|
123
|
+
elif response.status_code == 410:
|
|
124
|
+
return ("expired", None)
|
|
125
|
+
elif response.status_code == 403:
|
|
126
|
+
return ("denied", None)
|
|
127
|
+
elif response.status_code == 404:
|
|
128
|
+
return ("expired", None)
|
|
129
|
+
else:
|
|
130
|
+
response.raise_for_status()
|
|
131
|
+
return ("error", None)
|
|
132
|
+
|
|
133
|
+
def wait_for_authorization(
|
|
134
|
+
self,
|
|
135
|
+
on_pending: Callable[[int], None] | None = None,
|
|
136
|
+
timeout: int | None = None,
|
|
137
|
+
) -> str | None:
|
|
138
|
+
"""
|
|
139
|
+
Poll until authorized, denied, or expired.
|
|
140
|
+
Returns the API token if authorized, None otherwise.
|
|
141
|
+
on_pending is called with seconds_elapsed on each poll.
|
|
142
|
+
"""
|
|
143
|
+
start_time = time.time()
|
|
144
|
+
effective_timeout = timeout or self.expires_in
|
|
145
|
+
|
|
146
|
+
while True:
|
|
147
|
+
elapsed = int(time.time() - start_time)
|
|
148
|
+
|
|
149
|
+
if elapsed > effective_timeout:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
status, token = self.poll()
|
|
153
|
+
|
|
154
|
+
if status == "authorized":
|
|
155
|
+
return token
|
|
156
|
+
elif status in ("denied", "expired"):
|
|
157
|
+
return None
|
|
158
|
+
elif status == "pending":
|
|
159
|
+
if on_pending:
|
|
160
|
+
on_pending(elapsed)
|
|
161
|
+
time.sleep(self.interval)
|
|
162
|
+
else:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
"""Close the HTTP client."""
|
|
167
|
+
self._client.close()
|
appliku/_client.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Core HTTP client — the backbone of the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import Any, Protocol, TypeVar, runtime_checkable
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._exceptions import AplikuError, parse_error_response
|
|
12
|
+
from ._models import PaginatedResponse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class _Validatable(Protocol):
|
|
17
|
+
@classmethod
|
|
18
|
+
def model_validate(cls, data: Any) -> Any: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T", bound=_Validatable)
|
|
22
|
+
BASE_URL = "https://api.appliku.com"
|
|
23
|
+
MAX_RETRIES = 3
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AplikuClient:
|
|
27
|
+
"""Low-level HTTP client. Handles auth, retries, error parsing."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, token: str) -> None:
|
|
30
|
+
self._token = token
|
|
31
|
+
self._session = httpx.Client(
|
|
32
|
+
base_url=BASE_URL,
|
|
33
|
+
timeout=30.0,
|
|
34
|
+
headers={
|
|
35
|
+
"Authorization": f"Token {token}",
|
|
36
|
+
"Accept": "application/json",
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
41
|
+
"""Execute request with retry logic (429 + 5xx).
|
|
42
|
+
|
|
43
|
+
Retry strategy: up to MAX_RETRIES attempts. On 429, respect Retry-After header.
|
|
44
|
+
On 5xx, exponential backoff (1s, 2s, 4s). Raises on final failure.
|
|
45
|
+
"""
|
|
46
|
+
last_exc: AplikuError | None = None
|
|
47
|
+
|
|
48
|
+
for attempt in range(MAX_RETRIES):
|
|
49
|
+
response = self._session.request(method, path, **kwargs)
|
|
50
|
+
|
|
51
|
+
if response.status_code < 400:
|
|
52
|
+
return response
|
|
53
|
+
|
|
54
|
+
# Try to parse JSON body for error details
|
|
55
|
+
try:
|
|
56
|
+
body = response.json()
|
|
57
|
+
except Exception:
|
|
58
|
+
body = {"detail": response.text}
|
|
59
|
+
|
|
60
|
+
exc = parse_error_response(response.status_code, body)
|
|
61
|
+
|
|
62
|
+
if response.status_code == 429:
|
|
63
|
+
retry_after = int(response.headers.get("retry-after", "1"))
|
|
64
|
+
if attempt < MAX_RETRIES - 1:
|
|
65
|
+
time.sleep(retry_after)
|
|
66
|
+
last_exc = exc
|
|
67
|
+
continue
|
|
68
|
+
raise exc
|
|
69
|
+
|
|
70
|
+
if response.status_code >= 500:
|
|
71
|
+
if attempt < MAX_RETRIES - 1:
|
|
72
|
+
time.sleep(2**attempt) # 1s, 2s, 4s
|
|
73
|
+
last_exc = exc
|
|
74
|
+
continue
|
|
75
|
+
raise exc
|
|
76
|
+
|
|
77
|
+
# 4xx (except 429) — don't retry
|
|
78
|
+
raise exc
|
|
79
|
+
|
|
80
|
+
# Should not reach here, but just in case
|
|
81
|
+
if last_exc:
|
|
82
|
+
raise last_exc
|
|
83
|
+
raise AplikuError("Request failed after retries") # pragma: no cover
|
|
84
|
+
|
|
85
|
+
def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
86
|
+
response = self._request("GET", path, **kwargs)
|
|
87
|
+
return response.json() # type: ignore[no-any-return]
|
|
88
|
+
|
|
89
|
+
def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
90
|
+
response = self._request("POST", path, **kwargs)
|
|
91
|
+
return response.json() # type: ignore[no-any-return]
|
|
92
|
+
|
|
93
|
+
def put(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
94
|
+
response = self._request("PUT", path, **kwargs)
|
|
95
|
+
return response.json() # type: ignore[no-any-return]
|
|
96
|
+
|
|
97
|
+
def patch(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
98
|
+
response = self._request("PATCH", path, **kwargs)
|
|
99
|
+
return response.json() # type: ignore[no-any-return]
|
|
100
|
+
|
|
101
|
+
def delete(self, path: str) -> None:
|
|
102
|
+
self._request("DELETE", path)
|
|
103
|
+
|
|
104
|
+
def paginate(
|
|
105
|
+
self,
|
|
106
|
+
path: str,
|
|
107
|
+
params: dict[str, Any] | None = None,
|
|
108
|
+
response_type: type[T] = ..., # type: ignore[assignment]
|
|
109
|
+
) -> PaginatedResponse[T]:
|
|
110
|
+
"""Fetch one page, return PaginatedResponse with parsed models."""
|
|
111
|
+
data = self.get(path, params=params)
|
|
112
|
+
results = [response_type.model_validate(item) for item in data.get("results", [])]
|
|
113
|
+
return PaginatedResponse(
|
|
114
|
+
count=data.get("count", len(results)),
|
|
115
|
+
next=data.get("next"),
|
|
116
|
+
previous=data.get("previous"),
|
|
117
|
+
results=results,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def paginate_all(
|
|
121
|
+
self,
|
|
122
|
+
path: str,
|
|
123
|
+
params: dict[str, Any] | None = None,
|
|
124
|
+
response_type: type[T] = ..., # type: ignore[assignment]
|
|
125
|
+
) -> Iterator[T]:
|
|
126
|
+
"""Auto-follow pagination, yield individual items."""
|
|
127
|
+
current_path: str | None = path
|
|
128
|
+
current_params = params
|
|
129
|
+
while current_path is not None:
|
|
130
|
+
data = self.get(current_path, params=current_params)
|
|
131
|
+
for item in data.get("results", []):
|
|
132
|
+
yield response_type.model_validate(item)
|
|
133
|
+
next_url = data.get("next")
|
|
134
|
+
if next_url:
|
|
135
|
+
# Next URL is absolute; strip base to get relative path + query
|
|
136
|
+
current_path = next_url.replace(BASE_URL, "")
|
|
137
|
+
current_params = None # params are in the URL now
|
|
138
|
+
else:
|
|
139
|
+
current_path = None
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
self._session.close()
|
appliku/_exceptions.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Exception hierarchy for the Appliku SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AplikuError(Exception):
|
|
7
|
+
"""Base exception for all Appliku errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
status_code: int | None = None,
|
|
13
|
+
response: dict[str, object] | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.response = response
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthenticationError(AplikuError):
|
|
21
|
+
"""Authentication failed (401)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthorizationError(AplikuError):
|
|
25
|
+
"""Access denied (403)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotFoundError(AplikuError):
|
|
29
|
+
"""Resource not found (404)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ValidationError(AplikuError):
|
|
33
|
+
"""Request validation failed (400)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RateLimitError(AplikuError):
|
|
37
|
+
"""Rate limit exceeded (429)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ServerError(AplikuError):
|
|
41
|
+
"""Server-side error (5xx)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_error_response(status_code: int, body: dict[str, object]) -> AplikuError:
|
|
45
|
+
"""Factory: maps status code + DRF error shapes to the right exception.
|
|
46
|
+
|
|
47
|
+
Handles three DRF shapes:
|
|
48
|
+
- {"detail": "message"}
|
|
49
|
+
- {"non_field_errors": ["msg"]}
|
|
50
|
+
- {"field": ["error1", ...]}
|
|
51
|
+
"""
|
|
52
|
+
# Extract human-readable message
|
|
53
|
+
if "detail" in body:
|
|
54
|
+
message = str(body["detail"])
|
|
55
|
+
elif "non_field_errors" in body:
|
|
56
|
+
errors = body["non_field_errors"]
|
|
57
|
+
message = "; ".join(str(e) for e in errors) if isinstance(errors, list) else str(errors)
|
|
58
|
+
else:
|
|
59
|
+
# Field-level errors: {"field": ["error1", ...]}
|
|
60
|
+
parts: list[str] = []
|
|
61
|
+
for key, value in body.items():
|
|
62
|
+
if isinstance(value, list):
|
|
63
|
+
parts.append(f"{key}: {', '.join(str(v) for v in value)}")
|
|
64
|
+
else:
|
|
65
|
+
parts.append(f"{key}: {value}")
|
|
66
|
+
message = "; ".join(parts) if parts else str(body)
|
|
67
|
+
|
|
68
|
+
exc_class: type[AplikuError]
|
|
69
|
+
if status_code == 400:
|
|
70
|
+
exc_class = ValidationError
|
|
71
|
+
elif status_code == 401:
|
|
72
|
+
exc_class = AuthenticationError
|
|
73
|
+
elif status_code == 403:
|
|
74
|
+
exc_class = AuthorizationError
|
|
75
|
+
elif status_code == 404:
|
|
76
|
+
exc_class = NotFoundError
|
|
77
|
+
elif status_code == 429:
|
|
78
|
+
exc_class = RateLimitError
|
|
79
|
+
elif status_code >= 500:
|
|
80
|
+
exc_class = ServerError
|
|
81
|
+
else:
|
|
82
|
+
exc_class = AplikuError
|
|
83
|
+
|
|
84
|
+
return exc_class(message, status_code=status_code, response=body)
|
appliku/_main.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The Appliku class — the user-facing SDK entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._auth import resolve_token
|
|
6
|
+
from ._client import AplikuClient
|
|
7
|
+
from ._exceptions import AuthenticationError
|
|
8
|
+
from .resources.apps import AppsResource
|
|
9
|
+
from .resources.clusters import ClustersResource
|
|
10
|
+
from .resources.cron_jobs import CronJobsResource
|
|
11
|
+
from .resources.datastores import DatastoresResource
|
|
12
|
+
from .resources.deployments import DeploymentsResource
|
|
13
|
+
from .resources.domains import DomainsResource
|
|
14
|
+
from .resources.invites import InvitesResource
|
|
15
|
+
from .resources.migrations import MigrationsResource
|
|
16
|
+
from .resources.servers import ServersResource
|
|
17
|
+
from .resources.teams import TeamsResource
|
|
18
|
+
from .resources.volumes import VolumesResource
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Appliku:
|
|
22
|
+
"""Main SDK client. All resources are accessed as properties.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
client = Appliku(token="tok_...") # explicit
|
|
26
|
+
client = Appliku() # reads APPLIKU_TOKEN env var or config file
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, token: str | None = None) -> None:
|
|
30
|
+
resolved = resolve_token(token)
|
|
31
|
+
if not resolved:
|
|
32
|
+
raise AuthenticationError(
|
|
33
|
+
"No token found. Pass token=, set APPLIKU_TOKEN env var, or run `appliku login`."
|
|
34
|
+
)
|
|
35
|
+
self._client = AplikuClient(token=resolved)
|
|
36
|
+
|
|
37
|
+
self._teams: TeamsResource | None = None
|
|
38
|
+
self._apps: AppsResource | None = None
|
|
39
|
+
self._deployments: DeploymentsResource | None = None
|
|
40
|
+
self._datastores: DatastoresResource | None = None
|
|
41
|
+
self._domains: DomainsResource | None = None
|
|
42
|
+
self._volumes: VolumesResource | None = None
|
|
43
|
+
self._cron_jobs: CronJobsResource | None = None
|
|
44
|
+
self._clusters: ClustersResource | None = None
|
|
45
|
+
self._servers: ServersResource | None = None
|
|
46
|
+
self._invites: InvitesResource | None = None
|
|
47
|
+
self._migrations: MigrationsResource | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def teams(self) -> TeamsResource:
|
|
51
|
+
if self._teams is None:
|
|
52
|
+
self._teams = TeamsResource(self._client)
|
|
53
|
+
return self._teams
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def apps(self) -> AppsResource:
|
|
57
|
+
if self._apps is None:
|
|
58
|
+
self._apps = AppsResource(self._client)
|
|
59
|
+
return self._apps
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def deployments(self) -> DeploymentsResource:
|
|
63
|
+
if self._deployments is None:
|
|
64
|
+
self._deployments = DeploymentsResource(self._client)
|
|
65
|
+
return self._deployments
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def datastores(self) -> DatastoresResource:
|
|
69
|
+
if self._datastores is None:
|
|
70
|
+
self._datastores = DatastoresResource(self._client)
|
|
71
|
+
return self._datastores
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def domains(self) -> DomainsResource:
|
|
75
|
+
if self._domains is None:
|
|
76
|
+
self._domains = DomainsResource(self._client)
|
|
77
|
+
return self._domains
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def volumes(self) -> VolumesResource:
|
|
81
|
+
if self._volumes is None:
|
|
82
|
+
self._volumes = VolumesResource(self._client)
|
|
83
|
+
return self._volumes
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def cron_jobs(self) -> CronJobsResource:
|
|
87
|
+
if self._cron_jobs is None:
|
|
88
|
+
self._cron_jobs = CronJobsResource(self._client)
|
|
89
|
+
return self._cron_jobs
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def clusters(self) -> ClustersResource:
|
|
93
|
+
if self._clusters is None:
|
|
94
|
+
self._clusters = ClustersResource(self._client)
|
|
95
|
+
return self._clusters
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def servers(self) -> ServersResource:
|
|
99
|
+
if self._servers is None:
|
|
100
|
+
self._servers = ServersResource(self._client)
|
|
101
|
+
return self._servers
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def invites(self) -> InvitesResource:
|
|
105
|
+
if self._invites is None:
|
|
106
|
+
self._invites = InvitesResource(self._client)
|
|
107
|
+
return self._invites
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def migrations(self) -> MigrationsResource:
|
|
111
|
+
if self._migrations is None:
|
|
112
|
+
self._migrations = MigrationsResource(self._client)
|
|
113
|
+
return self._migrations
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
self._client.close()
|
|
117
|
+
|
|
118
|
+
def __enter__(self) -> Appliku:
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *args: object) -> None:
|
|
122
|
+
self.close()
|