llamactl 0.2.7a1__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.
@@ -0,0 +1,173 @@
1
+ """Configuration and profile management for llamactl"""
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+ from typing import Optional, List
6
+ from dataclasses import dataclass
7
+ import os
8
+
9
+
10
+ @dataclass
11
+ class Profile:
12
+ """Profile configuration"""
13
+
14
+ name: str
15
+ api_url: str
16
+ active_project_id: Optional[str] = None
17
+
18
+
19
+ class ConfigManager:
20
+ """Manages profiles and configuration using SQLite"""
21
+
22
+ def __init__(self):
23
+ self.config_dir = self._get_config_dir()
24
+ self.db_path = self.config_dir / "profiles.db"
25
+ self._ensure_config_dir()
26
+ self._init_database()
27
+
28
+ def _get_config_dir(self) -> Path:
29
+ """Get the configuration directory path based on OS"""
30
+ if os.name == "nt": # Windows
31
+ config_dir = Path(os.environ.get("APPDATA", "~")) / "llamactl"
32
+ else: # Unix-like (Linux, macOS)
33
+ config_dir = Path.home() / ".config" / "llamactl"
34
+ return config_dir.expanduser()
35
+
36
+ def _ensure_config_dir(self):
37
+ """Create configuration directory if it doesn't exist"""
38
+ self.config_dir.mkdir(parents=True, exist_ok=True)
39
+
40
+ def _init_database(self):
41
+ """Initialize SQLite database with required tables"""
42
+ with sqlite3.connect(self.db_path) as conn:
43
+ # Check if we need to migrate from old schema
44
+ cursor = conn.execute("PRAGMA table_info(profiles)")
45
+ columns = [row[1] for row in cursor.fetchall()]
46
+
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
+ """)
52
+
53
+ # Create tables with new schema
54
+ conn.execute("""
55
+ CREATE TABLE IF NOT EXISTS profiles (
56
+ name TEXT PRIMARY KEY,
57
+ api_url TEXT NOT NULL,
58
+ active_project_id TEXT
59
+ )
60
+ """)
61
+
62
+ conn.execute("""
63
+ CREATE TABLE IF NOT EXISTS settings (
64
+ key TEXT PRIMARY KEY,
65
+ value TEXT NOT NULL
66
+ )
67
+ """)
68
+
69
+ conn.commit()
70
+
71
+ def create_profile(
72
+ self, name: str, api_url: str, active_project_id: Optional[str] = None
73
+ ) -> Profile:
74
+ """Create a new profile"""
75
+ profile = Profile(
76
+ name=name, api_url=api_url, active_project_id=active_project_id
77
+ )
78
+
79
+ with sqlite3.connect(self.db_path) as conn:
80
+ try:
81
+ conn.execute(
82
+ "INSERT INTO profiles (name, api_url, active_project_id) VALUES (?, ?, ?)",
83
+ (profile.name, profile.api_url, profile.active_project_id),
84
+ )
85
+ conn.commit()
86
+ except sqlite3.IntegrityError:
87
+ raise ValueError(f"Profile '{name}' already exists")
88
+
89
+ return profile
90
+
91
+ def get_profile(self, name: str) -> Optional[Profile]:
92
+ """Get a profile by name"""
93
+ with sqlite3.connect(self.db_path) as conn:
94
+ cursor = conn.execute(
95
+ "SELECT name, api_url, active_project_id FROM profiles WHERE name = ?",
96
+ (name,),
97
+ )
98
+ row = cursor.fetchone()
99
+ if row:
100
+ return Profile(name=row[0], api_url=row[1], active_project_id=row[2])
101
+ return None
102
+
103
+ def list_profiles(self) -> List[Profile]:
104
+ """List all profiles"""
105
+ profiles = []
106
+ with sqlite3.connect(self.db_path) as conn:
107
+ cursor = conn.execute(
108
+ "SELECT name, api_url, active_project_id FROM profiles ORDER BY name"
109
+ )
110
+ for row in cursor.fetchall():
111
+ profiles.append(
112
+ Profile(name=row[0], api_url=row[1], active_project_id=row[2])
113
+ )
114
+ return profiles
115
+
116
+ def delete_profile(self, name: str) -> bool:
117
+ """Delete a profile by name. Returns True if deleted, False if not found."""
118
+ with sqlite3.connect(self.db_path) as conn:
119
+ cursor = conn.execute("DELETE FROM profiles WHERE name = ?", (name,))
120
+ conn.commit()
121
+
122
+ # If this was the active profile, clear it
123
+ if self.get_current_profile_name() == name:
124
+ self.set_current_profile(None)
125
+
126
+ return cursor.rowcount > 0
127
+
128
+ def set_current_profile(self, name: Optional[str]):
129
+ """Set the current active profile"""
130
+ with sqlite3.connect(self.db_path) as conn:
131
+ if name is None:
132
+ conn.execute("DELETE FROM settings WHERE key = 'current_profile'")
133
+ else:
134
+ conn.execute(
135
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_profile', ?)",
136
+ (name,),
137
+ )
138
+ conn.commit()
139
+
140
+ def get_current_profile_name(self) -> Optional[str]:
141
+ """Get the name of the current active profile"""
142
+ with sqlite3.connect(self.db_path) as conn:
143
+ cursor = conn.execute(
144
+ "SELECT value FROM settings WHERE key = 'current_profile'"
145
+ )
146
+ row = cursor.fetchone()
147
+ return row[0] if row else None
148
+
149
+ def get_current_profile(self) -> Optional[Profile]:
150
+ """Get the current active profile"""
151
+ current_name = self.get_current_profile_name()
152
+ if current_name:
153
+ return self.get_profile(current_name)
154
+ return None
155
+
156
+ def set_active_project(self, profile_name: str, project_id: Optional[str]) -> bool:
157
+ """Set the active project for a profile. Returns True if profile exists."""
158
+ with sqlite3.connect(self.db_path) as conn:
159
+ cursor = conn.execute(
160
+ "UPDATE profiles SET active_project_id = ? WHERE name = ?",
161
+ (project_id, profile_name),
162
+ )
163
+ conn.commit()
164
+ return cursor.rowcount > 0
165
+
166
+ def get_active_project(self, profile_name: str) -> Optional[str]:
167
+ """Get the active project for a profile"""
168
+ profile = self.get_profile(profile_name)
169
+ return profile.active_project_id if profile else None
170
+
171
+
172
+ # Global config manager instance
173
+ config_manager = ConfigManager()
@@ -0,0 +1,16 @@
1
+ import logging
2
+
3
+
4
+ def setup_file_logging(
5
+ log_file: str = "llamactl.log", level: int = logging.DEBUG
6
+ ) -> None:
7
+ """Set up global file logging for debugging when TUI takes over the terminal"""
8
+ # Configure the root logger
9
+ logging.basicConfig(
10
+ level=level,
11
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
12
+ handlers=[
13
+ logging.FileHandler(log_file, mode="a"),
14
+ ],
15
+ force=True, # Override any existing configuration
16
+ )
@@ -0,0 +1,30 @@
1
+ """Environment variable handling utilities for llamactl"""
2
+
3
+ from typing import Dict
4
+ from io import StringIO
5
+ from rich import print as rprint
6
+ from dotenv import dotenv_values
7
+
8
+
9
+ def load_env_secrets_from_string(env_content: str) -> Dict[str, str]:
10
+ """
11
+ Load environment variables from string content to use as secrets.
12
+
13
+ Args:
14
+ env_content: String content containing environment variables in .env format
15
+
16
+ Returns:
17
+ Dictionary of environment variable names and values
18
+ """
19
+ try:
20
+ # Use StringIO to create a file-like object from the string
21
+ # dotenv_values can parse from a stream
22
+ env_stream = StringIO(env_content)
23
+ secrets = dotenv_values(stream=env_stream)
24
+ # Filter out None values and convert to strings
25
+ return {k: str(v) for k, v in secrets.items() if v is not None}
26
+ except Exception as e:
27
+ rprint(
28
+ f"[yellow]Warning: Could not parse environment variables from string: {e}[/yellow]"
29
+ )
30
+ return {}
@@ -0,0 +1,86 @@
1
+ """Shared utilities for CLI operations"""
2
+
3
+ from typing import Optional
4
+
5
+ import questionary
6
+ from rich import print as rprint
7
+ from rich.console import Console
8
+
9
+ from ..client import get_client
10
+ from ..config import config_manager
11
+
12
+ console = Console()
13
+
14
+
15
+ def select_deployment(deployment_id: Optional[str] = None) -> Optional[str]:
16
+ """
17
+ Select a deployment interactively if ID not provided.
18
+ Returns the selected deployment ID or None if cancelled.
19
+ """
20
+ if deployment_id:
21
+ return deployment_id
22
+
23
+ try:
24
+ client = get_client()
25
+ deployments = client.list_deployments()
26
+
27
+ if not deployments:
28
+ rprint(
29
+ f"[yellow]No deployments found for project {client.project_id}[/yellow]"
30
+ )
31
+ return None
32
+
33
+ choices = []
34
+ for deployment in deployments:
35
+ name = deployment.name
36
+ deployment_id = deployment.id
37
+ status = deployment.status
38
+ choices.append(
39
+ questionary.Choice(
40
+ title=f"{name} ({deployment_id}) - {status}", value=deployment_id
41
+ )
42
+ )
43
+
44
+ return questionary.select("Select deployment:", choices=choices).ask()
45
+
46
+ except Exception as e:
47
+ rprint(f"[red]Error loading deployments: {e}[/red]")
48
+ return None
49
+
50
+
51
+ def select_profile(profile_name: Optional[str] = None) -> Optional[str]:
52
+ """
53
+ Select a profile interactively if name not provided.
54
+ Returns the selected profile name or None if cancelled.
55
+ """
56
+ if profile_name:
57
+ return profile_name
58
+
59
+ try:
60
+ profiles = config_manager.list_profiles()
61
+
62
+ if not profiles:
63
+ rprint("[yellow]No profiles found[/yellow]")
64
+ return None
65
+
66
+ choices = []
67
+ current_name = config_manager.get_current_profile_name()
68
+
69
+ for profile in profiles:
70
+ title = f"{profile.name} ({profile.api_url})"
71
+ if profile.name == current_name:
72
+ title += " [current]"
73
+ choices.append(questionary.Choice(title=title, value=profile.name))
74
+
75
+ return questionary.select("Select profile:", choices=choices).ask()
76
+
77
+ except Exception as e:
78
+ rprint(f"[red]Error loading profiles: {e}[/red]")
79
+ return None
80
+
81
+
82
+ def confirm_action(message: str, default: bool = False) -> bool:
83
+ """
84
+ Ask for confirmation with a consistent interface.
85
+ """
86
+ return questionary.confirm(message, default=default).ask() or False
@@ -0,0 +1,21 @@
1
+ import logging
2
+ import click
3
+
4
+
5
+ def global_options(f):
6
+ """Common decorator to add global options to command groups"""
7
+ from .debug import setup_file_logging
8
+
9
+ def debug_callback(ctx, param, value):
10
+ if value:
11
+ setup_file_logging(level=logging._nameToLevel[value])
12
+ return value
13
+
14
+ return click.option(
15
+ "--log-level",
16
+ type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
17
+ help="Enable debug logging to file",
18
+ callback=debug_callback,
19
+ expose_value=False,
20
+ is_eager=True,
21
+ )(f)