llamactl 0.2.7a1__py3-none-any.whl → 0.3.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.
Files changed (41) hide show
  1. llama_deploy/cli/__init__.py +9 -22
  2. llama_deploy/cli/app.py +69 -0
  3. llama_deploy/cli/auth/client.py +362 -0
  4. llama_deploy/cli/client.py +47 -170
  5. llama_deploy/cli/commands/aliased_group.py +33 -0
  6. llama_deploy/cli/commands/auth.py +696 -0
  7. llama_deploy/cli/commands/deployment.py +300 -0
  8. llama_deploy/cli/commands/env.py +211 -0
  9. llama_deploy/cli/commands/init.py +313 -0
  10. llama_deploy/cli/commands/serve.py +239 -0
  11. llama_deploy/cli/config/_config.py +390 -0
  12. llama_deploy/cli/config/_migrations.py +65 -0
  13. llama_deploy/cli/config/auth_service.py +130 -0
  14. llama_deploy/cli/config/env_service.py +67 -0
  15. llama_deploy/cli/config/migrations/0001_init.sql +35 -0
  16. llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
  17. llama_deploy/cli/config/migrations/__init__.py +7 -0
  18. llama_deploy/cli/config/schema.py +61 -0
  19. llama_deploy/cli/env.py +5 -3
  20. llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
  21. llama_deploy/cli/interactive_prompts/utils.py +6 -72
  22. llama_deploy/cli/options.py +27 -5
  23. llama_deploy/cli/py.typed +0 -0
  24. llama_deploy/cli/styles.py +10 -0
  25. llama_deploy/cli/textual/deployment_form.py +263 -36
  26. llama_deploy/cli/textual/deployment_help.py +53 -0
  27. llama_deploy/cli/textual/deployment_monitor.py +466 -0
  28. llama_deploy/cli/textual/git_validation.py +20 -21
  29. llama_deploy/cli/textual/github_callback_server.py +17 -14
  30. llama_deploy/cli/textual/llama_loader.py +13 -1
  31. llama_deploy/cli/textual/secrets_form.py +28 -8
  32. llama_deploy/cli/textual/styles.tcss +49 -8
  33. llama_deploy/cli/utils/env_inject.py +23 -0
  34. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/METADATA +9 -6
  35. llamactl-0.3.0.dist-info/RECORD +38 -0
  36. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/WHEEL +1 -1
  37. llama_deploy/cli/commands.py +0 -549
  38. llama_deploy/cli/config.py +0 -173
  39. llama_deploy/cli/textual/profile_form.py +0 -171
  40. llamactl-0.2.7a1.dist-info/RECORD +0 -19
  41. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -1,26 +1,10 @@
1
- import click
2
- from .commands import projects, deployments, profile, health_check, serve
3
- from .options import global_options
1
+ from llama_deploy.cli.commands.auth import auth
2
+ from llama_deploy.cli.commands.deployment import deployments
3
+ from llama_deploy.cli.commands.env import env_group
4
+ from llama_deploy.cli.commands.init import init
5
+ from llama_deploy.cli.commands.serve import serve
4
6
 
5
-
6
- # Main CLI application
7
- @click.group(help="LlamaDeploy CLI - Manage projects and deployments")
8
- @global_options
9
- def app():
10
- """LlamaDeploy CLI - Manage projects and deployments"""
11
- pass
12
-
13
-
14
- # Add sub-commands
15
- app.add_command(profile, name="profile")
16
- app.add_command(projects, name="project")
17
- app.add_command(deployments, name="deployment")
18
-
19
- # Add health check at root level
20
- app.add_command(health_check, name="health")
21
-
22
- # Add serve command at root level
23
- app.add_command(serve, name="serve")
7
+ from .app import app
24
8
 
25
9
 
26
10
  # Main entry point function (called by the script)
@@ -28,5 +12,8 @@ def main() -> None:
28
12
  app()
29
13
 
30
14
 
15
+ __all__ = ["app", "deployments", "auth", "serve", "init", "env_group"]
16
+
17
+
31
18
  if __name__ == "__main__":
32
19
  app()
@@ -0,0 +1,69 @@
1
+ from importlib.metadata import PackageNotFoundError
2
+ from importlib.metadata import version as pkg_version
3
+
4
+ import click
5
+ from llama_deploy.cli.commands.aliased_group import AliasedGroup
6
+ from llama_deploy.cli.config.env_service import service
7
+ from llama_deploy.cli.options import global_options
8
+ from rich import print as rprint
9
+ from rich.console import Console
10
+ from rich.text import Text
11
+
12
+ console = Console(highlight=False)
13
+
14
+
15
+ def print_version(ctx: click.Context, param: click.Option, value: bool) -> None:
16
+ """Print the version of llama_deploy"""
17
+ if not value or ctx.resilient_parsing:
18
+ return
19
+ try:
20
+ ver = pkg_version("llamactl")
21
+ console.print(Text.assemble("client version: ", (ver, "green")))
22
+
23
+ # If there is an active profile, attempt to query server version
24
+ auth_service = service.current_auth_service()
25
+ if auth_service:
26
+ try:
27
+ data = auth_service.fetch_server_version()
28
+ server_ver = data.version
29
+ console.print(
30
+ Text.assemble(
31
+ "server version: ",
32
+ (
33
+ server_ver or "unknown",
34
+ "bright_yellow" if server_ver is None else "green",
35
+ ),
36
+ )
37
+ )
38
+ except Exception as e:
39
+ console.print(
40
+ Text.assemble(
41
+ "server version: ",
42
+ ("unavailable", "bright_yellow"),
43
+ (f" - {e}", "dim"),
44
+ )
45
+ )
46
+ except PackageNotFoundError:
47
+ rprint("[red]Package 'llamactl' not found[/red]")
48
+ raise click.Abort()
49
+ except Exception as e:
50
+ rprint(f"[red]Error: {e}[/red]")
51
+ raise click.Abort()
52
+ ctx.exit()
53
+
54
+
55
+ # Main CLI application
56
+ @click.group(
57
+ help="Create, develop, and deploy LlamaIndex workflow based apps", cls=AliasedGroup
58
+ )
59
+ @click.option(
60
+ "--version",
61
+ is_flag=True,
62
+ callback=print_version,
63
+ expose_value=False,
64
+ is_eager=True,
65
+ help="Print client and server versions of LlamaDeploy",
66
+ )
67
+ @global_options
68
+ def app():
69
+ pass
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from types import TracebackType
6
+ from typing import Any, AsyncContextManager, AsyncGenerator, Awaitable, Callable, Self
7
+
8
+ import httpx
9
+ import jwt
10
+ from jwt.algorithms import RSAAlgorithm # type: ignore[possibly-unbound-import]
11
+ from llama_deploy.cli.config.schema import DeviceOIDC
12
+ from pydantic import BaseModel
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class OidcDiscoveryResponse(BaseModel):
18
+ discovery_url: str
19
+ client_ids: dict[str, str] | None = None
20
+
21
+
22
+ class OidcProviderConfiguration(BaseModel):
23
+ device_authorization_endpoint: str | None = None
24
+ token_endpoint: str | None = None
25
+ scopes_supported: list[str] | None = None
26
+ jwks_uri: str | None = None
27
+
28
+
29
+ class JsonWebKey(BaseModel):
30
+ kty: str
31
+ kid: str | None = None
32
+ use: str | None = None
33
+ alg: str | None = None
34
+ n: str | None = None
35
+ e: str | None = None
36
+ x5c: list[str] | None = None
37
+ x5t: str | None = None
38
+ x5t_s256: str | None = None
39
+
40
+
41
+ class JsonWebKeySet(BaseModel):
42
+ keys: list[JsonWebKey]
43
+
44
+
45
+ class AuthMeResponse(BaseModel):
46
+ id: str
47
+ email: str | None = None
48
+ last_login_provider: str | None = None
49
+ name: str | None = None
50
+ first_name: str | None = None
51
+ last_name: str | None = None
52
+ claims: dict[str, Any] | None = None
53
+ restrict: Any | None = None
54
+ created_at: str | None = None
55
+
56
+
57
+ class ClientContextManager(AsyncContextManager):
58
+ def __init__(self, base_url: str | None, auth: httpx.Auth | None = None) -> None:
59
+ self.base_url = base_url.rstrip("/") if base_url else None
60
+ if self.base_url:
61
+ self.client = httpx.AsyncClient(base_url=self.base_url, auth=auth)
62
+ else:
63
+ self.client = httpx.AsyncClient(auth=auth)
64
+
65
+ async def close(self) -> None:
66
+ try:
67
+ await self.client.aclose()
68
+ except Exception:
69
+ pass
70
+
71
+ async def __aenter__(self) -> Self:
72
+ return self
73
+
74
+ async def __aexit__(
75
+ self,
76
+ exc_type: type | None,
77
+ exc_value: BaseException | None,
78
+ traceback: TracebackType | None,
79
+ ) -> None:
80
+ await self.close()
81
+
82
+
83
+ class PlatformAuthDiscoveryClient(ClientContextManager):
84
+ """Client for ad hoc auth endpoints under /api/v1/auth."""
85
+
86
+ def __init__(self, base_url: str) -> None:
87
+ super().__init__(base_url)
88
+
89
+ async def oidc_discovery(self) -> OidcDiscoveryResponse:
90
+ resp = await self.client.get("/api/v1/auth/oidc/discovery", timeout=10.0)
91
+ resp.raise_for_status()
92
+ return OidcDiscoveryResponse.model_validate(resp.json())
93
+
94
+
95
+ class APIToken(BaseModel):
96
+ token: str
97
+ id: str
98
+
99
+
100
+ class PlatformAuthClient(ClientContextManager):
101
+ """Client for user introspection under /api/v1/auth/me."""
102
+
103
+ def __init__(
104
+ self, base_url: str, id_token: str | None = None, auth: httpx.Auth | None = None
105
+ ) -> None:
106
+ self.id_token = id_token
107
+ super().__init__(base_url, auth=auth)
108
+
109
+ async def me(self) -> AuthMeResponse:
110
+ headers = (
111
+ {"Authorization": f"Bearer {self.id_token}"} if self.id_token else None
112
+ )
113
+ resp = await self.client.get("/api/v1/auth/me", headers=headers, timeout=10.0)
114
+ resp.raise_for_status()
115
+ return AuthMeResponse.model_validate(resp.json())
116
+
117
+ async def create_agent_api_key(self, name: str) -> APIToken:
118
+ resp = await self.client.post(
119
+ "/api/v1/api-keys",
120
+ json={"name": name, "project_id": None},
121
+ )
122
+ resp.raise_for_status()
123
+ json = resp.json()
124
+ token = json["redacted_api_key"]
125
+ id = json["id"]
126
+ return APIToken(token=token, id=id)
127
+
128
+ async def delete_api_key(self, id: str) -> None:
129
+ response = await self.client.delete(f"/api/v1/api-keys/{id}")
130
+ response.raise_for_status()
131
+
132
+
133
+ class RefreshMiddleware(httpx.Auth):
134
+ def __init__(
135
+ self,
136
+ device_oidc: DeviceOIDC,
137
+ on_refresh: Callable[[DeviceOIDC], Awaitable[None]],
138
+ ) -> None:
139
+ self.device_oidc = device_oidc
140
+ self.on_refresh = on_refresh
141
+ self.lock = asyncio.Lock()
142
+
143
+ async def _refresh_and_update(self) -> None:
144
+ new_device_oidc = await refresh(self.device_oidc)
145
+ self.device_oidc = new_device_oidc
146
+ try:
147
+ await self.on_refresh(new_device_oidc)
148
+ except Exception:
149
+ logger.exception("Error in on_refresh callback")
150
+
151
+ async def async_auth_flow(
152
+ self, request: httpx.Request
153
+ ) -> AsyncGenerator[httpx.Request, httpx.Response]:
154
+ token = self.device_oidc.device_access_token
155
+ request.headers["Authorization"] = f"Bearer {token}"
156
+
157
+ response = yield request
158
+ if response.status_code == 401:
159
+ async with self.lock:
160
+ if token == self.device_oidc.device_access_token:
161
+ await self._refresh_and_update()
162
+ request.headers["Authorization"] = (
163
+ f"Bearer {self.device_oidc.device_access_token}"
164
+ )
165
+ yield request
166
+
167
+
168
+ class DeviceAuthorizationRequest(BaseModel):
169
+ client_id: str
170
+ scope: str
171
+
172
+
173
+ class DeviceAuthorizationResponse(BaseModel):
174
+ device_code: str
175
+ user_code: str
176
+ verification_uri: str
177
+ verification_uri_complete: str | None = None
178
+ expires_in: int
179
+ interval: int | None = None
180
+
181
+
182
+ class TokenRequestDeviceCode(BaseModel):
183
+ grant_type: str = "urn:ietf:params:oauth:grant-type:device_code"
184
+ device_code: str
185
+ client_id: str
186
+
187
+
188
+ class TokenResponse(BaseModel):
189
+ # Success fields
190
+ id_token: str | None = None
191
+ access_token: str | None = None
192
+ refresh_token: str | None = None
193
+ expires_in: int | None = None
194
+ token_type: str | None = None
195
+ scope: str | None = None
196
+ # Error fields
197
+ error: str | None = None
198
+ error_description: str | None = None
199
+
200
+
201
+ class TokenRequestRefresh(BaseModel):
202
+ grant_type: str = "refresh_token"
203
+ refresh_token: str
204
+ client_id: str
205
+
206
+
207
+ class OIDCClient(ClientContextManager):
208
+ def __init__(self) -> None:
209
+ super().__init__(None)
210
+
211
+ async def fetch_provider_configuration(
212
+ self, discovery_url: str
213
+ ) -> OidcProviderConfiguration:
214
+ resp = await self.client.get(discovery_url, timeout=10.0)
215
+ resp.raise_for_status()
216
+ return OidcProviderConfiguration.model_validate(resp.json())
217
+
218
+ async def device_authorization(
219
+ self, device_endpoint: str, request: DeviceAuthorizationRequest
220
+ ) -> DeviceAuthorizationResponse:
221
+ resp = await self.client.post(
222
+ device_endpoint,
223
+ data=request.model_dump(),
224
+ headers={
225
+ "Accept": "application/json",
226
+ "Content-Type": "application/x-www-form-urlencoded",
227
+ },
228
+ timeout=10.0,
229
+ )
230
+ resp.raise_for_status()
231
+ return DeviceAuthorizationResponse.model_validate(resp.json())
232
+
233
+ async def token_with_device_code(
234
+ self, token_endpoint: str, request: TokenRequestDeviceCode
235
+ ) -> TokenResponse:
236
+ resp = await self.client.post(
237
+ token_endpoint,
238
+ data=request.model_dump(),
239
+ headers={
240
+ "Accept": "application/json",
241
+ "Content-Type": "application/x-www-form-urlencoded",
242
+ },
243
+ timeout=10.0,
244
+ )
245
+ # Do not raise for status; callers inspect error payloads during polling
246
+ try:
247
+ payload = resp.json()
248
+ except Exception:
249
+ # Fall back to minimal error information
250
+ return TokenResponse(error="invalid_response", error_description=resp.text)
251
+ return TokenResponse.model_validate(payload)
252
+
253
+ async def token_with_refresh(
254
+ self, token_endpoint: str, request: TokenRequestRefresh
255
+ ) -> TokenResponse:
256
+ resp = await self.client.post(
257
+ token_endpoint,
258
+ data=request.model_dump(),
259
+ headers={
260
+ "Accept": "application/json",
261
+ "Content-Type": "application/x-www-form-urlencoded",
262
+ },
263
+ timeout=10.0,
264
+ )
265
+ try:
266
+ payload = resp.json()
267
+ except Exception:
268
+ return TokenResponse(error="invalid_response", error_description=resp.text)
269
+ return TokenResponse.model_validate(payload)
270
+
271
+ async def get_jwks(self, jwks_uri: str) -> JsonWebKeySet:
272
+ resp = await self.client.get(jwks_uri, timeout=10.0)
273
+ resp.raise_for_status()
274
+ return JsonWebKeySet.model_validate(resp.json())
275
+
276
+
277
+ async def decode_jwt_claims_from_device_oidc(
278
+ oidc_device: DeviceOIDC,
279
+ verify_audience: bool = False,
280
+ verify_expiration: bool = False,
281
+ audience: str | None = None,
282
+ ) -> dict[str, Any]:
283
+ """Decode JWT claims by discovering provider and verifying via JWKS.
284
+
285
+ Assumes RSA signing. Audience verification can be toggled and, when enabled,
286
+ an audience value can be provided.
287
+ """
288
+ if not oidc_device.device_id_token:
289
+ raise ValueError("Device ID token is missing. Cannot decode claims.")
290
+ async with OIDCClient() as oidc:
291
+ provider = await oidc.fetch_provider_configuration(oidc_device.discovery_url)
292
+ jwks_uri = provider.jwks_uri
293
+ if not jwks_uri:
294
+ raise ValueError("Provider does not expose jwks_uri")
295
+ return await decode_jwt_claims(
296
+ oidc_device.device_id_token,
297
+ jwks_uri,
298
+ verify_audience,
299
+ verify_expiration,
300
+ audience,
301
+ )
302
+
303
+
304
+ async def decode_jwt_claims(
305
+ token: str,
306
+ jwks_uri: str,
307
+ verify_audience: bool = False,
308
+ verify_expiration: bool = False,
309
+ audience: str | None = None,
310
+ ) -> dict[str, Any]:
311
+ async with OIDCClient() as oidc:
312
+ jwks = await oidc.get_jwks(jwks_uri)
313
+
314
+ # Select key
315
+ header = jwt.get_unverified_header(token)
316
+ kid = header.get("kid")
317
+ alg = header.get("alg", "RS256")
318
+ keys = jwks.keys
319
+ key = next((k for k in keys if k.kid == kid), None) or next(iter(keys), None)
320
+ if not key:
321
+ raise ValueError("Signing key not found in JWKS")
322
+
323
+ # Build public key (RSA-only)
324
+ if key.kty != "RSA":
325
+ raise ValueError("Unsupported JWK kty; only RSA is supported")
326
+ key_json = key.model_dump_json()
327
+ public_key = RSAAlgorithm.from_jwk(key_json)
328
+
329
+ return jwt.decode(
330
+ token,
331
+ public_key,
332
+ algorithms=[alg],
333
+ options={"verify_aud": verify_audience, "verify_exp": verify_expiration},
334
+ audience=audience,
335
+ )
336
+
337
+
338
+ async def refresh(device_oidc: DeviceOIDC) -> DeviceOIDC:
339
+ """
340
+ Run a refresh on the access token, storing updated tokens in a new DeviceOIDC.
341
+ """
342
+ async with OIDCClient() as oidc:
343
+ provider = await oidc.fetch_provider_configuration(device_oidc.discovery_url)
344
+ token_endpoint = provider.token_endpoint
345
+ if not token_endpoint:
346
+ raise ValueError("Provider does not expose token_endpoint")
347
+ if not device_oidc.device_refresh_token:
348
+ raise ValueError("Device refresh token is missing. Cannot refresh.")
349
+ token = await oidc.token_with_refresh(
350
+ token_endpoint,
351
+ TokenRequestRefresh(
352
+ refresh_token=device_oidc.device_refresh_token,
353
+ client_id=device_oidc.client_id,
354
+ ),
355
+ )
356
+ copy = device_oidc.model_copy()
357
+ if not token.access_token:
358
+ raise ValueError("Refresh failed: token response missing access_token")
359
+ copy.device_access_token = token.access_token
360
+ copy.device_refresh_token = token.refresh_token or copy.device_refresh_token
361
+ copy.device_id_token = token.id_token or copy.device_id_token
362
+ return copy
@@ -1,173 +1,50 @@
1
- import logging
2
- from typing import List, Optional
3
-
4
- import httpx
5
- from llama_deploy.core.schema.deployments import (
6
- DeploymentCreate,
7
- DeploymentResponse,
8
- DeploymentsListResponse,
9
- DeploymentUpdate,
10
- )
11
- from llama_deploy.core.schema.git_validation import (
12
- RepositoryValidationRequest,
13
- RepositoryValidationResponse,
14
- )
15
- from llama_deploy.core.schema.projects import ProjectSummary, ProjectsListResponse
16
- from rich.console import Console
17
-
18
- from .config import config_manager
19
-
20
-
21
- class LlamaDeployClient:
22
- """HTTP client for communicating with the LlamaDeploy control plane API"""
23
-
24
- def __init__(
25
- self, base_url: Optional[str] = None, project_id: Optional[str] = None
26
- ):
27
- """Initialize the client with a configured profile"""
28
- self.console = Console()
29
-
30
- # Get profile data
31
- profile = config_manager.get_current_profile()
32
- if not profile:
33
- self.console.print("\n[bold red]No profile configured![/bold red]")
34
- self.console.print("\nTo get started, create a profile with:")
35
- self.console.print("[cyan]llamactl profile create[/cyan]")
36
- raise SystemExit(1)
37
-
38
- # Use profile data with optional overrides
39
- self.base_url = base_url or profile.api_url
40
- self.project_id = project_id or profile.active_project_id
41
-
42
- if not self.base_url:
43
- raise ValueError("API URL is required")
44
-
45
- if not self.project_id:
46
- raise ValueError("Project ID is required")
47
-
48
- self.base_url = self.base_url.rstrip("/")
49
-
50
- # Create persistent client with event hooks
51
- self.client = httpx.Client(
52
- base_url=self.base_url, event_hooks={"response": [self._handle_response]}
1
+ from contextlib import asynccontextmanager
2
+ from typing import AsyncGenerator
3
+
4
+ from llama_deploy.cli.config.env_service import service
5
+ from llama_deploy.core.client.manage_client import ControlPlaneClient, ProjectClient
6
+ from rich import print as rprint
7
+
8
+
9
+ def get_control_plane_client() -> ControlPlaneClient:
10
+ auth_svc = service.current_auth_service()
11
+ profile = service.current_auth_service().get_current_profile()
12
+ if profile:
13
+ resolved_base_url = profile.api_url.rstrip("/")
14
+ resolved_api_key = profile.api_key
15
+ return ControlPlaneClient(
16
+ resolved_base_url, resolved_api_key, auth_svc.auth_middleware()
53
17
  )
54
18
 
55
- def _handle_response(self, response: httpx.Response) -> None:
56
- """Handle response middleware - warnings and error conversion"""
57
- # Check for warnings in response headers
58
- if "X-Warning" in response.headers:
59
- self.console.print(
60
- f"[yellow]Warning: {response.headers['X-Warning']}[/yellow]"
61
- )
62
-
63
- # Convert httpx errors to our current exception format
19
+ # Fallback: allow env-scoped client construction for env operations
20
+ env = service.get_current_environment()
21
+ resolved_base_url = env.api_url.rstrip("/")
22
+ return ControlPlaneClient(resolved_base_url)
23
+
24
+
25
+ def get_project_client() -> ProjectClient:
26
+ auth_svc = service.current_auth_service()
27
+ profile = auth_svc.get_current_profile()
28
+ if not profile:
29
+ rprint("\n[bold red]No profile configured![/bold red]")
30
+ rprint("\nTo get started, create a profile with:")
31
+ if auth_svc.env.requires_auth:
32
+ rprint("[cyan]llamactl auth login[/cyan]")
33
+ else:
34
+ rprint("[cyan]llamactl auth token[/cyan]")
35
+ raise SystemExit(1)
36
+ return ProjectClient(
37
+ profile.api_url, profile.project_id, profile.api_key, auth_svc.auth_middleware()
38
+ )
39
+
40
+
41
+ @asynccontextmanager
42
+ async def project_client_context() -> AsyncGenerator[ProjectClient, None]:
43
+ client = get_project_client()
44
+ try:
45
+ yield client
46
+ finally:
64
47
  try:
65
- response.raise_for_status()
66
- except httpx.HTTPStatusError as e:
67
- # Try to parse JSON error response
68
- try:
69
- response.read() # need to collect streaming data before calling json
70
- error_data = e.response.json()
71
- if isinstance(error_data, dict) and "detail" in error_data:
72
- error_message = error_data["detail"]
73
- else:
74
- error_message = str(error_data)
75
- except (ValueError, KeyError):
76
- # Fallback to raw response text
77
- error_message = e.response.text
78
-
79
- raise Exception(f"HTTP {e.response.status_code}: {error_message}") from e
80
- except httpx.RequestError as e:
81
- raise Exception(f"Request failed: {e}") from e
82
-
83
- # Health check
84
- def health_check(self) -> dict:
85
- """Check if the API server is healthy"""
86
- response = self.client.get("/health")
87
- return response.json()
88
-
89
- # Projects
90
- def list_projects(self) -> List[ProjectSummary]:
91
- """List all projects with deployment counts"""
92
- response = self.client.get("/projects/")
93
- projects_response = ProjectsListResponse.model_validate(response.json())
94
- return [project for project in projects_response.projects]
95
-
96
- # Deployments
97
- def list_deployments(self) -> List[DeploymentResponse]:
98
- """List deployments for the configured project"""
99
- response = self.client.get(f"/{self.project_id}/deployments/")
100
- deployments_response = DeploymentsListResponse.model_validate(response.json())
101
- return [deployment for deployment in deployments_response.deployments]
102
-
103
- def get_deployment(self, deployment_id: str) -> DeploymentResponse:
104
- """Get a specific deployment"""
105
- response = self.client.get(f"/{self.project_id}/deployments/{deployment_id}")
106
- deployment = DeploymentResponse.model_validate(response.json())
107
- return deployment
108
-
109
- def create_deployment(
110
- self,
111
- deployment_data: DeploymentCreate,
112
- ) -> DeploymentResponse:
113
- """Create a new deployment"""
114
-
115
- response = self.client.post(
116
- f"/{self.project_id}/deployments/",
117
- json=deployment_data.model_dump(exclude_none=True),
118
- )
119
- deployment = DeploymentResponse.model_validate(response.json())
120
- return deployment
121
-
122
- def delete_deployment(self, deployment_id: str) -> None:
123
- """Delete a deployment"""
124
- self.client.delete(f"/{self.project_id}/deployments/{deployment_id}")
125
-
126
- def update_deployment(
127
- self,
128
- deployment_id: str,
129
- update_data: DeploymentUpdate,
130
- force_git_sha_update: bool = False,
131
- ) -> DeploymentResponse:
132
- """Update an existing deployment"""
133
-
134
- params = {}
135
- if force_git_sha_update:
136
- params["force_git_sha_update"] = True
137
-
138
- response = self.client.patch(
139
- f"/{self.project_id}/deployments/{deployment_id}",
140
- json=update_data.model_dump(),
141
- params=params,
142
- )
143
- deployment = DeploymentResponse.model_validate(response.json())
144
- return deployment
145
-
146
- def validate_repository(
147
- self,
148
- repo_url: str,
149
- deployment_id: str | None = None,
150
- pat: str | None = None,
151
- ) -> RepositoryValidationResponse:
152
- """Validate a repository URL"""
153
- logging.info(
154
- f"Validating repository with params: {repo_url}, {deployment_id}, {pat}"
155
- )
156
- response = self.client.post(
157
- f"/{self.project_id}/deployments/validate-repository",
158
- json=RepositoryValidationRequest(
159
- repository_url=repo_url,
160
- deployment_id=deployment_id,
161
- pat=pat,
162
- ).model_dump(),
163
- )
164
- logging.info(f"Response: {response.json()}")
165
- return RepositoryValidationResponse.model_validate(response.json())
166
-
167
-
168
- # Global client factory function
169
- def get_client(
170
- base_url: Optional[str] = None, project_id: Optional[str] = None
171
- ) -> LlamaDeployClient:
172
- """Get a client instance with optional overrides"""
173
- return LlamaDeployClient(base_url=base_url, project_id=project_id)
48
+ await client.aclose()
49
+ except Exception:
50
+ pass