llamactl 0.3.0a12__py3-none-any.whl → 0.3.0a13__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.
@@ -6,18 +6,24 @@ A deployment points the control plane at your Git repository and deployment file
6
6
  git ref, reads the config, and runs your app.
7
7
  """
8
8
 
9
+ import asyncio
10
+
9
11
  import click
12
+ import questionary
13
+ from llama_deploy.cli.commands.auth import validate_authenticated_profile
10
14
  from llama_deploy.core.schema.deployments import DeploymentUpdate
11
15
  from rich import print as rprint
12
16
  from rich.table import Table
13
17
 
14
18
  from ..app import app, console
15
19
  from ..client import get_project_client
20
+ from ..interactive_prompts.session_utils import (
21
+ is_interactive_session,
22
+ )
16
23
  from ..interactive_prompts.utils import (
17
24
  confirm_action,
18
- select_deployment,
19
25
  )
20
- from ..options import global_options
26
+ from ..options import global_options, interactive_option
21
27
  from ..textual.deployment_form import create_deployment_form, edit_deployment_form
22
28
  from ..textual.deployment_monitor import monitor_deployment_screen
23
29
 
@@ -35,11 +41,13 @@ def deployments() -> None:
35
41
  # Deployments commands
36
42
  @deployments.command("list")
37
43
  @global_options
38
- def list_deployments() -> None:
44
+ @interactive_option
45
+ def list_deployments(interactive: bool) -> None:
39
46
  """List deployments for the configured project."""
47
+ validate_authenticated_profile(interactive)
40
48
  try:
41
49
  client = get_project_client()
42
- deployments = client.list_deployments()
50
+ deployments = asyncio.run(client.list_deployments())
43
51
 
44
52
  if not deployments:
45
53
  rprint(
@@ -89,13 +97,10 @@ def list_deployments() -> None:
89
97
  @deployments.command("get")
90
98
  @global_options
91
99
  @click.argument("deployment_id", required=False)
92
- @click.option(
93
- "--non-interactive",
94
- is_flag=True,
95
- help="Do not open a live monitor screen showing status and streaming logs",
96
- )
97
- def get_deployment(deployment_id: str | None, non_interactive: bool) -> None:
100
+ @interactive_option
101
+ def get_deployment(deployment_id: str | None, interactive: bool) -> None:
98
102
  """Get details of a specific deployment"""
103
+ validate_authenticated_profile(interactive)
99
104
  try:
100
105
  client = get_project_client()
101
106
 
@@ -103,12 +108,11 @@ def get_deployment(deployment_id: str | None, non_interactive: bool) -> None:
103
108
  if not deployment_id:
104
109
  rprint("[yellow]No deployment selected[/yellow]")
105
110
  return
106
-
107
- if not non_interactive:
111
+ if interactive:
108
112
  monitor_deployment_screen(deployment_id)
109
113
  return
110
114
 
111
- deployment = client.get_deployment(deployment_id)
115
+ deployment = asyncio.run(client.get_deployment(deployment_id))
112
116
 
113
117
  table = Table(title=f"Deployment: {deployment.name}")
114
118
  table.add_column("Property", style="cyan")
@@ -139,22 +143,18 @@ def get_deployment(deployment_id: str | None, non_interactive: bool) -> None:
139
143
 
140
144
  @deployments.command("create")
141
145
  @global_options
142
- @click.option("--repo-url", help="HTTP(S) Git Repository URL")
143
- @click.option("--name", help="Deployment name")
144
- @click.option("--deployment-file-path", help="Path to deployment file")
145
- @click.option("--git-ref", help="Git reference (branch, tag, or commit)")
146
- @click.option(
147
- "--personal-access-token", help="Git Personal Access Token (HTTP Basic Auth)"
148
- )
146
+ @interactive_option
149
147
  def create_deployment(
150
- repo_url: str | None,
151
- name: str | None,
152
- deployment_file_path: str | None,
153
- git_ref: str | None,
154
- personal_access_token: str | None,
148
+ interactive: bool,
155
149
  ) -> None:
156
150
  """Interactively create a new deployment"""
157
151
 
152
+ if not interactive:
153
+ raise click.ClickException(
154
+ "This command requires an interactive session. Run in a terminal or provide required arguments explicitly."
155
+ )
156
+ validate_authenticated_profile(interactive)
157
+
158
158
  # Use interactive creation
159
159
  deployment_form = create_deployment_form()
160
160
  if deployment_form is None:
@@ -169,23 +169,24 @@ def create_deployment(
169
169
  @deployments.command("delete")
170
170
  @global_options
171
171
  @click.argument("deployment_id", required=False)
172
- @click.option("--confirm", is_flag=True, help="Skip confirmation prompt")
173
- def delete_deployment(deployment_id: str | None, confirm: bool) -> None:
172
+ @interactive_option
173
+ def delete_deployment(deployment_id: str | None, interactive: bool) -> None:
174
174
  """Delete a deployment"""
175
+ validate_authenticated_profile(interactive)
175
176
  try:
176
177
  client = get_project_client()
177
178
 
178
- deployment_id = select_deployment(deployment_id)
179
+ deployment_id = select_deployment(deployment_id, interactive=interactive)
179
180
  if not deployment_id:
180
181
  rprint("[yellow]No deployment selected[/yellow]")
181
182
  return
182
183
 
183
- if not confirm:
184
+ if interactive:
184
185
  if not confirm_action(f"Delete deployment '{deployment_id}'?"):
185
186
  rprint("[yellow]Cancelled[/yellow]")
186
187
  return
187
188
 
188
- client.delete_deployment(deployment_id)
189
+ asyncio.run(client.delete_deployment(deployment_id))
189
190
  rprint(f"[green]Deleted deployment: {deployment_id}[/green]")
190
191
 
191
192
  except Exception as e:
@@ -196,18 +197,20 @@ def delete_deployment(deployment_id: str | None, confirm: bool) -> None:
196
197
  @deployments.command("edit")
197
198
  @global_options
198
199
  @click.argument("deployment_id", required=False)
199
- def edit_deployment(deployment_id: str | None) -> None:
200
+ @interactive_option
201
+ def edit_deployment(deployment_id: str | None, interactive: bool) -> None:
200
202
  """Interactively edit a deployment"""
203
+ validate_authenticated_profile(interactive)
201
204
  try:
202
205
  client = get_project_client()
203
206
 
204
- deployment_id = select_deployment(deployment_id)
207
+ deployment_id = select_deployment(deployment_id, interactive=interactive)
205
208
  if not deployment_id:
206
209
  rprint("[yellow]No deployment selected[/yellow]")
207
210
  return
208
211
 
209
212
  # Get current deployment details
210
- current_deployment = client.get_deployment(deployment_id)
213
+ current_deployment = asyncio.run(client.get_deployment(deployment_id))
211
214
 
212
215
  # Use the interactive edit form
213
216
  updated_deployment = edit_deployment_form(current_deployment)
@@ -227,8 +230,10 @@ def edit_deployment(deployment_id: str | None) -> None:
227
230
  @deployments.command("update")
228
231
  @global_options
229
232
  @click.argument("deployment_id", required=False)
230
- def refresh_deployment(deployment_id: str | None) -> None:
233
+ @interactive_option
234
+ def refresh_deployment(deployment_id: str | None, interactive: bool) -> None:
231
235
  """Update the deployment, pulling the latest code from it's branch"""
236
+ validate_authenticated_profile(interactive)
232
237
  try:
233
238
  client = get_project_client()
234
239
 
@@ -238,16 +243,18 @@ def refresh_deployment(deployment_id: str | None) -> None:
238
243
  return
239
244
 
240
245
  # Get current deployment details to show what we're refreshing
241
- current_deployment = client.get_deployment(deployment_id)
246
+ current_deployment = asyncio.run(client.get_deployment(deployment_id))
242
247
  deployment_name = current_deployment.name
243
248
  old_git_sha = current_deployment.git_sha or ""
244
249
 
245
250
  # Create an empty update to force git SHA refresh with spinner
246
251
  with console.status(f"Refreshing {deployment_name}..."):
247
252
  deployment_update = DeploymentUpdate()
248
- updated_deployment = client.update_deployment(
249
- deployment_id,
250
- deployment_update,
253
+ updated_deployment = asyncio.run(
254
+ client.update_deployment(
255
+ deployment_id,
256
+ deployment_update,
257
+ )
251
258
  )
252
259
 
253
260
  # Show the git SHA change with short SHAs
@@ -263,3 +270,47 @@ def refresh_deployment(deployment_id: str | None) -> None:
263
270
  except Exception as e:
264
271
  rprint(f"[red]Error: {e}[/red]")
265
272
  raise click.Abort()
273
+
274
+
275
+ def select_deployment(
276
+ deployment_id: str | None = None, interactive: bool = is_interactive_session()
277
+ ) -> str | None:
278
+ """
279
+ Select a deployment interactively if ID not provided.
280
+ Returns the selected deployment ID or None if cancelled.
281
+
282
+ In non-interactive sessions, returns None if deployment_id is not provided.
283
+ """
284
+ if deployment_id:
285
+ return deployment_id
286
+
287
+ # Don't attempt interactive selection in non-interactive sessions
288
+ if not interactive:
289
+ return None
290
+
291
+ try:
292
+ client = get_project_client()
293
+ deployments = asyncio.run(client.list_deployments())
294
+
295
+ if not deployments:
296
+ rprint(
297
+ f"[yellow]No deployments found for project {client.project_id}[/yellow]"
298
+ )
299
+ return None
300
+
301
+ choices = []
302
+ for deployment in deployments:
303
+ name = deployment.name
304
+ deployment_id = deployment.id
305
+ status = deployment.status
306
+ choices.append(
307
+ questionary.Choice(
308
+ title=f"{name} ({deployment_id}) - {status}", value=deployment_id
309
+ )
310
+ )
311
+
312
+ return questionary.select("Select deployment:", choices=choices).ask()
313
+
314
+ except Exception as e:
315
+ rprint(f"[red]Error loading deployments: {e}[/red]")
316
+ return None
@@ -13,7 +13,8 @@ class Profile:
13
13
 
14
14
  name: str
15
15
  api_url: str
16
- active_project_id: str | None = None
16
+ project_id: str
17
+ api_key_auth_token: str | None = None
17
18
 
18
19
 
19
20
  class ConfigManager:
@@ -24,9 +25,16 @@ class ConfigManager:
24
25
  self.db_path = self.config_dir / "profiles.db"
25
26
  self._ensure_config_dir()
26
27
  self._init_database()
28
+ self.default_control_plane_url = "https://api.llamacloud.com"
27
29
 
28
30
  def _get_config_dir(self) -> Path:
29
- """Get the configuration directory path based on OS"""
31
+ """Get the configuration directory path based on OS.
32
+
33
+ Honors LLAMACTL_CONFIG_DIR when set. This helps tests isolate state.
34
+ """
35
+ override = os.environ.get("LLAMACTL_CONFIG_DIR")
36
+ if override:
37
+ return Path(override).expanduser()
30
38
  if os.name == "nt": # Windows
31
39
  config_dir = Path(os.environ.get("APPDATA", "~")) / "llamactl"
32
40
  else: # Unix-like (Linux, macOS)
@@ -44,18 +52,35 @@ class ConfigManager:
44
52
  cursor = conn.execute("PRAGMA table_info(profiles)")
45
53
  columns = [row[1] for row in cursor.fetchall()]
46
54
 
47
- if "project_id" in columns and "active_project_id" not in columns:
48
- # Migrate old schema to new schema
49
- conn.execute("""
50
- ALTER TABLE profiles RENAME COLUMN project_id TO active_project_id
51
- """)
55
+ # Migration: handle old active_project_id -> project_id and make it required
56
+ if "active_project_id" in columns and "project_id" not in columns:
57
+ # Delete any profiles that have no active_project_id since project_id is now required
58
+ conn.execute(
59
+ "DELETE FROM profiles WHERE active_project_id IS NULL OR active_project_id = ''"
60
+ )
61
+
62
+ # Rename active_project_id to project_id
63
+ # Note: SQLite doesn't allow changing column constraints easily, but we enforce
64
+ # the NOT NULL constraint in our application code and new table creation
65
+ conn.execute(
66
+ "ALTER TABLE profiles RENAME COLUMN active_project_id TO project_id"
67
+ )
52
68
 
53
- # Create tables with new schema
69
+ # Add api_key_auth_token column if not already present
70
+ mig_cursor = conn.execute("PRAGMA table_info(profiles)")
71
+ mig_columns = [row[1] for row in mig_cursor.fetchall()]
72
+ if "api_key_auth_token" not in mig_columns:
73
+ conn.execute(
74
+ "ALTER TABLE profiles ADD COLUMN api_key_auth_token TEXT"
75
+ )
76
+
77
+ # Create tables with new schema (this will only create if they don't exist)
54
78
  conn.execute("""
55
79
  CREATE TABLE IF NOT EXISTS profiles (
56
80
  name TEXT PRIMARY KEY,
57
81
  api_url TEXT NOT NULL,
58
- active_project_id TEXT
82
+ project_id TEXT NOT NULL,
83
+ api_key_auth_token TEXT
59
84
  )
60
85
  """)
61
86
 
@@ -69,18 +94,32 @@ class ConfigManager:
69
94
  conn.commit()
70
95
 
71
96
  def create_profile(
72
- self, name: str, api_url: str, active_project_id: str | None = None
97
+ self,
98
+ name: str,
99
+ api_url: str,
100
+ project_id: str,
101
+ api_key_auth_token: str | None = None,
73
102
  ) -> Profile:
74
103
  """Create a new profile"""
104
+ if not project_id.strip():
105
+ raise ValueError("Project ID is required")
75
106
  profile = Profile(
76
- name=name, api_url=api_url, active_project_id=active_project_id
107
+ name=name,
108
+ api_url=api_url,
109
+ project_id=project_id,
110
+ api_key_auth_token=api_key_auth_token,
77
111
  )
78
112
 
79
113
  with sqlite3.connect(self.db_path) as conn:
80
114
  try:
81
115
  conn.execute(
82
- "INSERT INTO profiles (name, api_url, active_project_id) VALUES (?, ?, ?)",
83
- (profile.name, profile.api_url, profile.active_project_id),
116
+ "INSERT INTO profiles (name, api_url, project_id, api_key_auth_token) VALUES (?, ?, ?, ?)",
117
+ (
118
+ profile.name,
119
+ profile.api_url,
120
+ profile.project_id,
121
+ profile.api_key_auth_token,
122
+ ),
84
123
  )
85
124
  conn.commit()
86
125
  except sqlite3.IntegrityError:
@@ -92,12 +131,17 @@ class ConfigManager:
92
131
  """Get a profile by name"""
93
132
  with sqlite3.connect(self.db_path) as conn:
94
133
  cursor = conn.execute(
95
- "SELECT name, api_url, active_project_id FROM profiles WHERE name = ?",
134
+ "SELECT name, api_url, project_id, api_key_auth_token FROM profiles WHERE name = ?",
96
135
  (name,),
97
136
  )
98
137
  row = cursor.fetchone()
99
138
  if row:
100
- return Profile(name=row[0], api_url=row[1], active_project_id=row[2])
139
+ return Profile(
140
+ name=row[0],
141
+ api_url=row[1],
142
+ project_id=row[2],
143
+ api_key_auth_token=row[3],
144
+ )
101
145
  return None
102
146
 
103
147
  def list_profiles(self) -> List[Profile]:
@@ -105,11 +149,16 @@ class ConfigManager:
105
149
  profiles = []
106
150
  with sqlite3.connect(self.db_path) as conn:
107
151
  cursor = conn.execute(
108
- "SELECT name, api_url, active_project_id FROM profiles ORDER BY name"
152
+ "SELECT name, api_url, project_id, api_key_auth_token FROM profiles ORDER BY name"
109
153
  )
110
154
  for row in cursor.fetchall():
111
155
  profiles.append(
112
- Profile(name=row[0], api_url=row[1], active_project_id=row[2])
156
+ Profile(
157
+ name=row[0],
158
+ api_url=row[1],
159
+ project_id=row[2],
160
+ api_key_auth_token=row[3],
161
+ )
113
162
  )
114
163
  return profiles
115
164
 
@@ -151,22 +200,41 @@ class ConfigManager:
151
200
  current_name = self.get_current_profile_name()
152
201
  if current_name:
153
202
  return self.get_profile(current_name)
203
+ profiles = self.list_profiles()
204
+ if len(profiles) == 1:
205
+ return profiles[0]
154
206
  return None
155
207
 
156
- def set_active_project(self, profile_name: str, project_id: str | None) -> bool:
157
- """Set the active project for a profile. Returns True if profile exists."""
208
+ def set_project(self, profile_name: str, project_id: str) -> bool:
209
+ """Set the project for a profile. Returns True if profile exists."""
158
210
  with sqlite3.connect(self.db_path) as conn:
159
211
  cursor = conn.execute(
160
- "UPDATE profiles SET active_project_id = ? WHERE name = ?",
212
+ "UPDATE profiles SET project_id = ? WHERE name = ?",
161
213
  (project_id, profile_name),
162
214
  )
163
215
  conn.commit()
164
216
  return cursor.rowcount > 0
165
217
 
166
- def get_active_project(self, profile_name: str) -> str | None:
167
- """Get the active project for a profile"""
218
+ def set_default_control_plane_url(self, url: str) -> None:
219
+ """Set the default control plane URL for the current session"""
220
+ self.default_control_plane_url = url
221
+
222
+ def get_project(self, profile_name: str) -> str | None:
223
+ """Get the project for a profile"""
168
224
  profile = self.get_profile(profile_name)
169
- return profile.active_project_id if profile else None
225
+ return profile.project_id if profile else None
226
+
227
+ def set_api_key_auth_token(
228
+ self, profile_name: str, api_key_auth_token: str | None
229
+ ) -> bool:
230
+ """Set the API key auth token for a profile. Returns True if profile exists."""
231
+ with sqlite3.connect(self.db_path) as conn:
232
+ cursor = conn.execute(
233
+ "UPDATE profiles SET api_key_auth_token = ? WHERE name = ?",
234
+ (api_key_auth_token, profile_name),
235
+ )
236
+ conn.commit()
237
+ return cursor.rowcount > 0
170
238
 
171
239
 
172
240
  # Global config manager instance
@@ -0,0 +1,37 @@
1
+ """Utilities for detecting and handling interactive CLI sessions."""
2
+
3
+ import functools
4
+ import os
5
+ import sys
6
+
7
+
8
+ @functools.cache
9
+ def is_interactive_session() -> bool:
10
+ """
11
+ Detect if the current CLI session is interactive.
12
+
13
+ Returns True if the session is interactive (user can be prompted),
14
+ False if it's non-interactive (e.g., CI/CD, scripted environment).
15
+
16
+ This function checks multiple indicators:
17
+ - Whether stdin/stdout are connected to a TTY
18
+ - Explicit non-interactive environment variables
19
+
20
+ Examples:
21
+ >>> if is_interactive_session():
22
+ ... user_input = questionary.text("Enter value:").ask()
23
+ ... else:
24
+ ... raise click.ClickException("Value required in non-interactive mode")
25
+ """
26
+
27
+ # Check if stdin and stdout are TTYs
28
+ # This is the most reliable indicator for interactive sessions
29
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
30
+ return False
31
+
32
+ # Additional check for TERM environment variable
33
+ # Some environments set TERM=dumb for non-interactive sessions
34
+ if os.environ.get("TERM") == "dumb":
35
+ return False
36
+
37
+ return True
@@ -4,56 +4,26 @@ import questionary
4
4
  from rich import print as rprint
5
5
  from rich.console import Console
6
6
 
7
- from ..client import get_project_client as get_client
8
7
  from ..config import config_manager
8
+ from .session_utils import is_interactive_session
9
9
 
10
10
  console = Console()
11
11
 
12
12
 
13
- def select_deployment(deployment_id: str | None = None) -> str | None:
14
- """
15
- Select a deployment interactively if ID not provided.
16
- Returns the selected deployment ID or None if cancelled.
17
- """
18
- if deployment_id:
19
- return deployment_id
20
-
21
- try:
22
- client = get_client()
23
- deployments = client.list_deployments()
24
-
25
- if not deployments:
26
- rprint(
27
- f"[yellow]No deployments found for project {client.project_id}[/yellow]"
28
- )
29
- return None
30
-
31
- choices = []
32
- for deployment in deployments:
33
- name = deployment.name
34
- deployment_id = deployment.id
35
- status = deployment.status
36
- choices.append(
37
- questionary.Choice(
38
- title=f"{name} ({deployment_id}) - {status}", value=deployment_id
39
- )
40
- )
41
-
42
- return questionary.select("Select deployment:", choices=choices).ask()
43
-
44
- except Exception as e:
45
- rprint(f"[red]Error loading deployments: {e}[/red]")
46
- return None
47
-
48
-
49
13
  def select_profile(profile_name: str | None = None) -> str | None:
50
14
  """
51
15
  Select a profile interactively if name not provided.
52
16
  Returns the selected profile name or None if cancelled.
17
+
18
+ In non-interactive sessions, returns None if profile_name is not provided.
53
19
  """
54
20
  if profile_name:
55
21
  return profile_name
56
22
 
23
+ # Don't attempt interactive selection in non-interactive sessions
24
+ if not is_interactive_session():
25
+ return None
26
+
57
27
  try:
58
28
  profiles = config_manager.list_profiles()
59
29
 
@@ -80,5 +50,10 @@ def select_profile(profile_name: str | None = None) -> str | None:
80
50
  def confirm_action(message: str, default: bool = False) -> bool:
81
51
  """
82
52
  Ask for confirmation with a consistent interface.
53
+
54
+ In non-interactive sessions, returns the default value without prompting.
83
55
  """
56
+ if not is_interactive_session():
57
+ return default
58
+
84
59
  return questionary.confirm(message, default=default).ask() or False
@@ -2,6 +2,10 @@ import logging
2
2
  from typing import Callable, ParamSpec, TypeVar
3
3
 
4
4
  import click
5
+ from llama_deploy.cli.config import config_manager
6
+ from llama_deploy.cli.interactive_prompts.session_utils import is_interactive_session
7
+
8
+ from .debug import setup_file_logging
5
9
 
6
10
  P = ParamSpec("P")
7
11
  R = TypeVar("R")
@@ -9,7 +13,6 @@ R = TypeVar("R")
9
13
 
10
14
  def global_options(f: Callable[P, R]) -> Callable[P, R]:
11
15
  """Common decorator to add global options to command groups"""
12
- from .debug import setup_file_logging
13
16
 
14
17
  def debug_callback(ctx: click.Context, param: click.Parameter, value: str) -> str:
15
18
  if value:
@@ -27,3 +30,23 @@ def global_options(f: Callable[P, R]) -> Callable[P, R]:
27
30
  is_eager=True,
28
31
  hidden=True,
29
32
  )(f)
33
+
34
+
35
+ def control_plane_url_callback(
36
+ ctx: click.Context, param: click.Parameter, value: str
37
+ ) -> str:
38
+ if value:
39
+ config_manager.set_default_control_plane_url(value)
40
+ return value
41
+
42
+
43
+ def interactive_option(f: Callable[P, R]) -> Callable[P, R]:
44
+ """Add an interactive option to the command"""
45
+
46
+ default = is_interactive_session()
47
+ return click.option(
48
+ "--interactive/--no-interactive",
49
+ help="Run in interactive mode. If not provided, will default to the current session's interactive state.",
50
+ is_flag=True,
51
+ default=default,
52
+ )(f)
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ import httpx
6
+ from pydantic import BaseModel, TypeAdapter
7
+
8
+
9
+ class PlatformClient:
10
+ def __init__(self, auth_token: str, platform_url: str):
11
+ self.auth_token = auth_token
12
+ self.platform_url = platform_url
13
+ self.client = httpx.AsyncClient(base_url=platform_url)
14
+
15
+ async def list_projects(self) -> list[Project]:
16
+ response = await self.client.get("/api/v1/projects")
17
+ response.raise_for_status()
18
+ return ProjectList.validate_python(response.json())
19
+
20
+ async def list_organizations(self) -> list[Organization]:
21
+ response = await self.client.get("/api/v1/organizations")
22
+ response.raise_for_status()
23
+ return OrganizationList.validate_python(response.json())
24
+
25
+ async def validate_auth_token(self) -> bool:
26
+ response = await self.client.get("/api/v1/organizations/default")
27
+ try:
28
+ response.raise_for_status()
29
+ return True
30
+ except httpx.HTTPStatusError:
31
+ if response.status_code == 401:
32
+ return False
33
+ raise
34
+
35
+
36
+ class Organization(BaseModel):
37
+ id: str
38
+ name: str
39
+ created_at: datetime
40
+ updated_at: datetime
41
+
42
+
43
+ class Project(BaseModel):
44
+ id: str
45
+ name: str
46
+ organization_id: str
47
+ created_at: datetime
48
+ updated_at: datetime
49
+
50
+
51
+ OrganizationList = TypeAdapter(list[Organization])
52
+ ProjectList = TypeAdapter(list[Project])