llamactl 0.3.0a19__py3-none-any.whl → 0.3.0a21__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.
@@ -1,19 +1,37 @@
1
1
  """Configuration and profile management for llamactl"""
2
2
 
3
+ import functools
3
4
  import os
4
5
  import sqlite3
6
+ import uuid
5
7
  from pathlib import Path
6
8
  from typing import Any
7
9
 
8
- from .schema import DEFAULT_ENVIRONMENT, Auth, Environment
10
+ from ._migrations import run_migrations
11
+ from .schema import DEFAULT_ENVIRONMENT, Auth, DeviceOIDC, Environment
12
+
13
+
14
+ def _serialize_device_oidc(value: DeviceOIDC | None) -> str | None:
15
+ if value is None:
16
+ return None
17
+ return value.model_dump_json()
18
+
19
+
20
+ def _deserialize_device_oidc(value: str | None) -> DeviceOIDC | None:
21
+ if not value:
22
+ return None
23
+ return DeviceOIDC.model_validate_json(value)
9
24
 
10
25
 
11
26
  def _to_auth(row: Any) -> Auth:
12
27
  return Auth(
13
- name=row[0],
14
- api_url=row[1],
15
- project_id=row[2],
16
- api_key=row[3],
28
+ id=row[0],
29
+ name=row[1],
30
+ api_url=row[2],
31
+ project_id=row[3],
32
+ api_key=row[4],
33
+ api_key_id=row[5],
34
+ device_oidc=_deserialize_device_oidc(row[6]),
17
35
  )
18
36
 
19
37
 
@@ -28,11 +46,12 @@ def _to_environment(row: Any) -> Environment:
28
46
  class ConfigManager:
29
47
  """Manages profiles and configuration using SQLite"""
30
48
 
31
- def __init__(self):
49
+ def __init__(self, init_database: bool = True):
32
50
  self.config_dir = self._get_config_dir()
33
51
  self.db_path = self.config_dir / "profiles.db"
34
52
  self._ensure_config_dir()
35
- self._init_database()
53
+ if init_database:
54
+ self._init_database()
36
55
 
37
56
  def _get_config_dir(self) -> Path:
38
57
  """Get the configuration directory path based on OS.
@@ -53,125 +72,58 @@ class ConfigManager:
53
72
  self.config_dir.mkdir(parents=True, exist_ok=True)
54
73
 
55
74
  def _init_database(self):
56
- """Initialize SQLite database with required tables"""
57
- with sqlite3.connect(self.db_path) as conn:
58
- # Check if we need to migrate from old schema
59
- cursor = conn.execute("PRAGMA table_info(profiles)")
60
- columns = [row[1] for row in cursor.fetchall()]
75
+ """Initialize SQLite database and run migrations; then seed defaults."""
61
76
 
62
- # Migration: handle old active_project_id -> project_id and make it required
63
- if "active_project_id" in columns and "project_id" not in columns:
64
- # Delete any profiles that have no active_project_id since project_id is now required
65
- conn.execute(
66
- "DELETE FROM profiles WHERE active_project_id IS NULL OR active_project_id = ''"
67
- )
77
+ with sqlite3.connect(self.db_path) as conn:
78
+ # Apply ad-hoc SQL migrations based on PRAGMA user_version
79
+ run_migrations(conn)
68
80
 
69
- # Rename active_project_id to project_id
70
- # Note: SQLite doesn't allow changing column constraints easily, but we enforce
71
- # the NOT NULL constraint in our application code and new table creation
72
- conn.execute(
73
- "ALTER TABLE profiles RENAME COLUMN active_project_id TO project_id"
74
- )
81
+ conn.commit()
75
82
 
76
- # Add api_key column if not already present
77
- mig_cursor = conn.execute("PRAGMA table_info(profiles)")
78
- mig_columns = [row[1] for row in mig_cursor.fetchall()]
79
- if "api_key" not in mig_columns:
80
- conn.execute("ALTER TABLE profiles ADD COLUMN api_key TEXT")
81
-
82
- # Migration: change profiles primary key from name to composite (name, api_url)
83
- # Only perform if the table exists with single-column PK on name
84
- pk_info_cur = conn.execute("PRAGMA table_info(profiles)")
85
- pk_info_rows = pk_info_cur.fetchall()
86
- pk_columns = [row[1] for row in pk_info_rows if len(row) > 5 and row[5] > 0]
87
- if pk_columns == ["name"] and "api_url" in columns:
88
- conn.execute("ALTER TABLE profiles RENAME TO profiles_old")
89
- conn.execute(
90
- """
91
- CREATE TABLE profiles (
92
- name TEXT NOT NULL,
93
- api_url TEXT NOT NULL,
94
- project_id TEXT NOT NULL,
95
- api_key TEXT,
96
- PRIMARY KEY (name, api_url)
97
- )
98
- """
99
- )
100
- conn.execute(
101
- """
102
- INSERT INTO profiles (name, api_url, project_id, api_key)
103
- SELECT name, api_url, project_id, api_key FROM profiles_old
104
- """
105
- )
106
- conn.execute("DROP TABLE profiles_old")
107
-
108
- # Create tables with new schema (this will only create if they don't exist)
109
- conn.execute("""
110
- CREATE TABLE IF NOT EXISTS profiles (
111
- name TEXT NOT NULL,
112
- api_url TEXT NOT NULL,
113
- project_id TEXT NOT NULL,
114
- api_key TEXT,
115
- PRIMARY KEY (name, api_url)
116
- )
117
- """)
83
+ def destroy_database(self):
84
+ """Destroy the database"""
85
+ self.db_path.unlink()
86
+ self._init_database()
118
87
 
119
- conn.execute("""
120
- CREATE TABLE IF NOT EXISTS settings (
121
- key TEXT PRIMARY KEY,
122
- value TEXT NOT NULL
123
- )
124
- """)
88
+ #############################################
89
+ ## Settings
90
+ #############################################
125
91
 
126
- # Environments: first-class environments table
127
- conn.execute(
128
- """
129
- CREATE TABLE IF NOT EXISTS environments (
130
- api_url TEXT PRIMARY KEY,
131
- requires_auth INTEGER NOT NULL,
132
- min_llamactl_version TEXT
133
- )
134
- """
135
- )
92
+ def set_settings_current_profile(self, name: str | None):
93
+ """Set or clear the current active profile.
136
94
 
137
- # Ensure there is a current environment setting. If missing, set to default.
138
- setting_cursor = conn.execute(
139
- "SELECT value FROM settings WHERE key = 'current_environment_api_url'"
140
- )
141
- setting_row = setting_cursor.fetchone()
142
- if not setting_row:
95
+ If name is None, the setting is removed.
96
+ """
97
+ with sqlite3.connect(self.db_path) as conn:
98
+ if name is None:
99
+ conn.execute("DELETE FROM settings WHERE key = 'current_profile'")
100
+ else:
143
101
  conn.execute(
144
- "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
145
- (DEFAULT_ENVIRONMENT.api_url,),
102
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_profile', ?)",
103
+ (name,),
146
104
  )
105
+ conn.commit()
147
106
 
148
- # Seed environments from existing profiles if environments is empty
149
- env_count_cur = conn.execute("SELECT COUNT(*) FROM environments")
150
- env_count = env_count_cur.fetchone()[0]
151
- if env_count == 0:
152
- # Insert distinct api_url values from profiles with requires_auth = 0 (False)
153
- conn.execute(
154
- """
155
- INSERT OR IGNORE INTO environments (api_url, requires_auth)
156
- SELECT DISTINCT api_url, 0 FROM profiles
157
- """
158
- )
107
+ def get_settings_current_profile_name(self) -> str | None:
108
+ """Get the name of the current active profile"""
109
+ with sqlite3.connect(self.db_path) as conn:
110
+ cursor = conn.execute(
111
+ "SELECT value FROM settings WHERE key = 'current_profile'"
112
+ )
113
+ row = cursor.fetchone()
114
+ return row[0] if row else None
159
115
 
160
- # Also ensure the current environment exists as a row
161
- cur_env_cursor = conn.execute(
162
- "SELECT value FROM settings WHERE key = 'current_environment_api_url'"
163
- )
164
- cur_env_row = cur_env_cursor.fetchone()
165
- if cur_env_row:
166
- conn.execute(
167
- "INSERT OR IGNORE INTO environments (api_url, requires_auth, min_llamactl_version) VALUES (?, ?, ?)",
168
- (
169
- cur_env_row[0],
170
- 1 if DEFAULT_ENVIRONMENT.requires_auth else 0,
171
- DEFAULT_ENVIRONMENT.min_llamactl_version,
172
- ),
173
- )
116
+ def set_settings_current_environment(self, api_url: str) -> None:
117
+ """Set the current environment by URL.
174
118
 
119
+ Requires the environment row to already exist (validated elsewhere, e.g. via
120
+ a probe before creation). Raises ValueError if not found.
121
+ """
122
+ with sqlite3.connect(self.db_path) as conn:
123
+ conn.execute(
124
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
125
+ (api_url,),
126
+ )
175
127
  conn.commit()
176
128
 
177
129
  def create_profile(
@@ -180,26 +132,34 @@ class ConfigManager:
180
132
  api_url: str,
181
133
  project_id: str,
182
134
  api_key: str | None = None,
135
+ api_key_id: str | None = None,
136
+ device_oidc: DeviceOIDC | None = None,
183
137
  ) -> Auth:
184
138
  """Create a new auth profile"""
185
139
  if not project_id.strip():
186
140
  raise ValueError("Project ID is required")
187
141
  profile = Auth(
142
+ id=str(uuid.uuid4()),
188
143
  name=name,
189
144
  api_url=api_url,
190
145
  project_id=project_id,
191
146
  api_key=api_key,
147
+ api_key_id=api_key_id,
148
+ device_oidc=device_oidc,
192
149
  )
193
150
 
194
151
  with sqlite3.connect(self.db_path) as conn:
195
152
  try:
196
153
  conn.execute(
197
- "INSERT INTO profiles (name, api_url, project_id, api_key) VALUES (?, ?, ?, ?)",
154
+ "INSERT INTO profiles (id, name, api_url, project_id, api_key, api_key_id, device_oidc) VALUES (?, ?, ?, ?, ?, ?, ?)",
198
155
  (
156
+ profile.id,
199
157
  profile.name,
200
158
  profile.api_url,
201
159
  profile.project_id,
202
160
  profile.api_key,
161
+ profile.api_key_id,
162
+ _serialize_device_oidc(profile.device_oidc),
203
163
  ),
204
164
  )
205
165
  conn.commit()
@@ -210,24 +170,101 @@ class ConfigManager:
210
170
 
211
171
  return profile
212
172
 
173
+ def get_current_profile(self, env_url: str) -> Auth | None:
174
+ """Get the current active profile"""
175
+ current_name = self.get_settings_current_profile_name()
176
+ if current_name:
177
+ return self.get_profile(current_name, env_url)
178
+ return None
179
+
180
+ def get_current_environment(self) -> Environment:
181
+ """Get the current active environment"""
182
+ with sqlite3.connect(self.db_path) as conn:
183
+ cursor = conn.execute(
184
+ "SELECT value FROM settings WHERE key = 'current_environment_api_url'"
185
+ )
186
+ row = cursor.fetchone()
187
+ api_url = row[0] if row else DEFAULT_ENVIRONMENT.api_url
188
+
189
+ env_row = conn.execute(
190
+ "SELECT api_url, requires_auth, min_llamactl_version FROM environments WHERE api_url = ?",
191
+ (api_url,),
192
+ ).fetchone()
193
+ if env_row:
194
+ return _to_environment(env_row)
195
+
196
+ # Fallback: return an in-memory default without writing to DB
197
+ if api_url == DEFAULT_ENVIRONMENT.api_url:
198
+ return DEFAULT_ENVIRONMENT
199
+ return Environment(
200
+ api_url=api_url, requires_auth=False, min_llamactl_version=None
201
+ )
202
+
203
+ ##################################
204
+ ## Profiles
205
+ ##################################
206
+
213
207
  def get_profile(self, name: str, env_url: str) -> Auth | None:
214
208
  """Get a profile by name"""
215
209
  with sqlite3.connect(self.db_path) as conn:
216
210
  row = conn.execute(
217
- "SELECT name, api_url, project_id, api_key FROM profiles WHERE name = ? AND api_url = ?",
211
+ "SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE name = ? AND api_url = ?",
218
212
  (name, env_url),
219
213
  ).fetchone()
220
214
  if row:
221
215
  return _to_auth(row)
222
216
  return None
223
217
 
218
+ def get_profile_by_id(self, id: str) -> Auth | None:
219
+ """Get a profile by ID"""
220
+ with sqlite3.connect(self.db_path) as conn:
221
+ row = conn.execute(
222
+ "SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE id = ?",
223
+ (id,),
224
+ ).fetchone()
225
+ if row:
226
+ return _to_auth(row)
227
+ return None
228
+
229
+ def get_profile_by_api_key(self, env_url: str, api_key: str) -> Auth | None:
230
+ """Get a profile by api_key within an environment URL."""
231
+ with sqlite3.connect(self.db_path) as conn:
232
+ row = conn.execute(
233
+ """
234
+ SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc
235
+ FROM profiles
236
+ WHERE api_url = ? AND api_key = ?
237
+ LIMIT 1
238
+ """,
239
+ (env_url, api_key),
240
+ ).fetchone()
241
+ if row:
242
+ return _to_auth(row)
243
+ return None
244
+
245
+ def get_profile_by_device_user_id(self, env_url: str, user_id: str) -> Auth | None:
246
+ """Get a profile by device OIDC user_id within an environment URL."""
247
+ with sqlite3.connect(self.db_path) as conn:
248
+ row = conn.execute(
249
+ """
250
+ SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc
251
+ FROM profiles
252
+ WHERE api_url = ? AND JSON_EXTRACT(device_oidc, '$.user_id') = ?
253
+ LIMIT 1
254
+ """,
255
+ (env_url, user_id),
256
+ ).fetchone()
257
+ if row:
258
+ return _to_auth(row)
259
+ return None
260
+
224
261
  def list_profiles(self, env_url: str) -> list[Auth]:
225
262
  """List all profiles"""
226
263
  with sqlite3.connect(self.db_path) as conn:
227
264
  return [
228
265
  _to_auth(row)
229
266
  for row in conn.execute(
230
- "SELECT name, api_url, project_id, api_key FROM profiles WHERE api_url = ? ORDER BY name",
267
+ "SELECT id, name, api_url, project_id, api_key, api_key_id, device_oidc FROM profiles WHERE api_url = ? ORDER BY name",
231
268
  (env_url,),
232
269
  ).fetchall()
233
270
  ]
@@ -246,37 +283,6 @@ class ConfigManager:
246
283
 
247
284
  return cursor.rowcount > 0
248
285
 
249
- def set_settings_current_profile(self, name: str | None):
250
- """Set or clear the current active profile.
251
-
252
- If name is None, the setting is removed.
253
- """
254
- with sqlite3.connect(self.db_path) as conn:
255
- if name is None:
256
- conn.execute("DELETE FROM settings WHERE key = 'current_profile'")
257
- else:
258
- conn.execute(
259
- "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_profile', ?)",
260
- (name,),
261
- )
262
- conn.commit()
263
-
264
- def get_settings_current_profile_name(self) -> str | None:
265
- """Get the name of the current active profile"""
266
- with sqlite3.connect(self.db_path) as conn:
267
- cursor = conn.execute(
268
- "SELECT value FROM settings WHERE key = 'current_profile'"
269
- )
270
- row = cursor.fetchone()
271
- return row[0] if row else None
272
-
273
- def get_current_profile(self, env_url: str) -> Auth | None:
274
- """Get the current active profile"""
275
- current_name = self.get_settings_current_profile_name()
276
- if current_name:
277
- return self.get_profile(current_name, env_url)
278
- return None
279
-
280
286
  def set_project(self, profile_name: str, env_url: str, project_id: str) -> bool:
281
287
  """Set the project for a profile. Returns True if profile exists."""
282
288
  with sqlite3.connect(self.db_path) as conn:
@@ -287,7 +293,26 @@ class ConfigManager:
287
293
  conn.commit()
288
294
  return cursor.rowcount > 0
289
295
 
290
- # Environment management APIs
296
+ def update_profile(self, profile: Auth) -> None:
297
+ """Update a profile"""
298
+ with sqlite3.connect(self.db_path) as conn:
299
+ conn.execute(
300
+ "UPDATE profiles SET name = ?, api_url = ?, project_id = ?, api_key = ?, api_key_id = ?, device_oidc = ? WHERE id = ?",
301
+ (
302
+ profile.name,
303
+ profile.api_url,
304
+ profile.project_id,
305
+ profile.api_key,
306
+ profile.api_key_id,
307
+ _serialize_device_oidc(profile.device_oidc),
308
+ profile.id,
309
+ ),
310
+ )
311
+ conn.commit()
312
+
313
+ ##################################
314
+ ## Environments
315
+ ##################################
291
316
  def create_or_update_environment(
292
317
  self, api_url: str, requires_auth: bool, min_llamactl_version: str | None = None
293
318
  ) -> None:
@@ -323,57 +348,6 @@ class ConfigManager:
323
348
  envs = [DEFAULT_ENVIRONMENT]
324
349
  return envs
325
350
 
326
- def set_settings_current_environment(self, api_url: str) -> None:
327
- """Set the current environment by URL.
328
-
329
- Requires the environment row to already exist (validated elsewhere, e.g. via
330
- a probe before creation). Raises ValueError if not found.
331
- """
332
- with sqlite3.connect(self.db_path) as conn:
333
- conn.execute(
334
- "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
335
- (api_url,),
336
- )
337
- conn.commit()
338
-
339
- def get_current_environment(self) -> Environment:
340
- """Get the current environment.
341
-
342
- Ensures there is a settings entry and a corresponding row in `environments`.
343
- """
344
- with sqlite3.connect(self.db_path) as conn:
345
- cursor = conn.execute(
346
- "SELECT value FROM settings WHERE key = 'current_environment_api_url'"
347
- )
348
- row = cursor.fetchone()
349
- api_url = row[0] if row else DEFAULT_ENVIRONMENT.api_url
350
- if not row:
351
- conn.execute(
352
- "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
353
- (api_url,),
354
- )
355
-
356
- # Ensure environment exists
357
- conn.execute(
358
- "INSERT OR IGNORE INTO environments (api_url, requires_auth, min_llamactl_version) VALUES (?, ?, ?)",
359
- (
360
- api_url,
361
- 1 if DEFAULT_ENVIRONMENT.requires_auth else 0,
362
- DEFAULT_ENVIRONMENT.min_llamactl_version,
363
- ),
364
- )
365
-
366
- # Read the environment
367
- env_row = conn.execute(
368
- "SELECT api_url, requires_auth, min_llamactl_version FROM environments WHERE api_url = ?",
369
- (api_url,),
370
- ).fetchone()
371
- if env_row:
372
- return _to_environment(env_row)
373
-
374
- # If we somehow got here without returning, raise an error
375
- raise RuntimeError("Failed to load current environment")
376
-
377
351
  def delete_environment(self, api_url: str) -> bool:
378
352
  """Delete an environment and all associated profiles.
379
353
 
@@ -411,4 +385,6 @@ class ConfigManager:
411
385
 
412
386
 
413
387
  # Global config manager instance
414
- config_manager = ConfigManager()
388
+ @functools.cache
389
+ def config_manager() -> ConfigManager:
390
+ return ConfigManager()
@@ -0,0 +1,65 @@
1
+ """Ad-hoc SQLite schema migrations using PRAGMA user_version.
2
+
3
+ Inspired by https://eskerda.com/sqlite-schema-migrations-python/
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+ import sqlite3
11
+ from importlib import import_module, resources
12
+ from importlib.resources.abc import Traversable
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ _MIGRATIONS_PKG = "llama_deploy.cli.config.migrations"
18
+ _USER_VERSION_PATTERN = re.compile(r"pragma\s+user_version\s*=\s*(\d+)", re.IGNORECASE)
19
+
20
+
21
+ def _iter_migration_files() -> list[Traversable]:
22
+ """Yield packaged SQL migration files in lexicographic order."""
23
+ pkg = import_module(_MIGRATIONS_PKG)
24
+ root = resources.files(pkg)
25
+ files = (p for p in root.iterdir() if p.name.endswith(".sql"))
26
+ if not files:
27
+ raise ValueError("No migration files found")
28
+ return sorted(files, key=lambda p: p.name)
29
+
30
+
31
+ def _parse_target_version(sql_text: str) -> int | None:
32
+ """Return target schema version declared in the first PRAGMA line, if any."""
33
+ first_line = sql_text.splitlines()[0] if sql_text else ""
34
+ match = _USER_VERSION_PATTERN.search(first_line)
35
+ return int(match.group(1)) if match else None
36
+
37
+
38
+ def run_migrations(conn: sqlite3.Connection) -> None:
39
+ """Apply pending migrations found under the migrations package.
40
+
41
+ Each migration file should start with a `PRAGMA user_version=N;` line.
42
+ Files are applied in lexicographic order and only when N > current_version.
43
+ """
44
+ cur = conn.cursor()
45
+ current_version_row = cur.execute("PRAGMA user_version").fetchone()
46
+ current_version = int(current_version_row[0]) if current_version_row else 0
47
+
48
+ for path in _iter_migration_files():
49
+ sql_text = path.read_text()
50
+ target_version = _parse_target_version(sql_text) or 0
51
+ if target_version <= current_version:
52
+ continue
53
+
54
+ try:
55
+ logger.debug(
56
+ "Applying migration %s → target version %s", path.name, target_version
57
+ )
58
+ cur.executescript("BEGIN;\n" + sql_text)
59
+ except Exception as exc: # noqa: BLE001 – we surface the exact error
60
+ logger.error("Failed migration %s: %s", path.name, exc)
61
+ cur.execute("ROLLBACK")
62
+ raise
63
+ else:
64
+ cur.execute("COMMIT")
65
+ current_version = target_version
@@ -1,7 +1,9 @@
1
1
  import asyncio
2
2
 
3
+ from llama_deploy.cli.auth.client import PlatformAuthClient, RefreshMiddleware
3
4
  from llama_deploy.cli.config._config import Auth, ConfigManager, Environment
4
- from llama_deploy.core.client.manage_client import ControlPlaneClient
5
+ from llama_deploy.cli.config.schema import DeviceOIDC
6
+ from llama_deploy.core.client.manage_client import ControlPlaneClient, httpx
5
7
  from llama_deploy.core.schema import VersionResponse
6
8
  from llama_deploy.core.schema.projects import ProjectSummary
7
9
 
@@ -17,6 +19,9 @@ class AuthService:
17
19
  def get_profile(self, name: str) -> Auth | None:
18
20
  return self.config_manager.get_profile(name, self.env.api_url)
19
21
 
22
+ def get_profile_by_id(self, id: str) -> Auth | None:
23
+ return self.config_manager.get_profile_by_id(id)
24
+
20
25
  def set_current_profile(self, name: str) -> None:
21
26
  self.config_manager.set_settings_current_profile(name)
22
27
 
@@ -37,7 +42,35 @@ class AuthService:
37
42
  self.config_manager.set_settings_current_profile(auth.name)
38
43
  return auth
39
44
 
40
- def delete_profile(self, name: str) -> bool:
45
+ def create_or_update_profile_from_oidc(
46
+ self, project_id: str, device_oidc: DeviceOIDC
47
+ ) -> Auth:
48
+ base = device_oidc.email
49
+ existing = self.config_manager.get_profile_by_device_user_id(
50
+ self.env.api_url, device_oidc.user_id
51
+ )
52
+ if existing:
53
+ existing.device_oidc = device_oidc
54
+ self.config_manager.update_profile(existing)
55
+ auth = existing
56
+ else:
57
+ auth = self.config_manager.create_profile(
58
+ base, self.env.api_url, project_id, device_oidc=device_oidc
59
+ )
60
+ self.config_manager.set_settings_current_profile(auth.name)
61
+ return auth
62
+
63
+ def update_profile(self, profile: Auth) -> None:
64
+ self.config_manager.update_profile(profile)
65
+
66
+ async def delete_profile(self, name: str) -> bool:
67
+ profile = self.get_profile(name)
68
+ if profile and profile.api_key_id:
69
+ async with self.profile_client(profile) as client:
70
+ try:
71
+ await client.delete_api_key(profile.api_key_id)
72
+ except Exception:
73
+ pass
41
74
  return self.config_manager.delete_profile(name, self.env.api_url)
42
75
 
43
76
  def set_project(self, name: str, project_id: str) -> None:
@@ -58,6 +91,35 @@ class AuthService:
58
91
 
59
92
  return asyncio.run(_run())
60
93
 
94
+ def auth_middleware(self, profile: Auth | None = None) -> httpx.Auth | None:
95
+ profile = profile or self.get_current_profile()
96
+ if profile and profile.device_oidc:
97
+ _profile = profile # copy to assist type checker being inflexible
98
+
99
+ async def _on_refresh(updated: DeviceOIDC) -> None:
100
+ # Persist refreshed tokens to the database synchronously within async wrapper
101
+ self.refresh_to_db(_profile.id, updated)
102
+
103
+ return RefreshMiddleware(
104
+ profile.device_oidc,
105
+ _on_refresh,
106
+ )
107
+ return None
108
+
109
+ def refresh_to_db(self, profile_id: str, device_oidc: DeviceOIDC) -> None:
110
+ profile = self.get_profile_by_id(profile_id)
111
+ if profile:
112
+ profile.device_oidc = device_oidc
113
+ self.config_manager.update_profile(profile)
114
+
115
+ def profile_client(self, profile: Auth | None = None) -> PlatformAuthClient:
116
+ profile = profile or self.get_current_profile()
117
+ if not profile:
118
+ raise ValueError("No active profile")
119
+ return PlatformAuthClient(
120
+ profile.api_url, profile.api_key, self.auth_middleware(profile)
121
+ )
122
+
61
123
 
62
124
  def _auto_profile_name_from_token(api_key: str) -> str:
63
125
  token = api_key or "token"