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 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()