cloudshellgpt 1.0.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.
@@ -0,0 +1,296 @@
1
+ """Configuration manager — handles user settings and defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from pydantic import Field, field_validator
11
+ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Constants
15
+ # ---------------------------------------------------------------------------
16
+
17
+ CONFIG_DIR = Path.home() / ".csgpt"
18
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Custom exceptions
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ class ConfigError(Exception):
27
+ """Raised when configuration loading, saving, or validation fails."""
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Enums
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ class OutputFormat(StrEnum):
36
+ """Supported output formats."""
37
+
38
+ TABLE = "table"
39
+ JSON = "json"
40
+ YAML = "yaml"
41
+ CSV = "csv"
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # YAML settings source
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ class YamlSettingsSource(PydanticBaseSettingsSource):
50
+ """Load settings from a YAML file on disk."""
51
+
52
+ def __init__(self, settings_cls: type[BaseSettings], yaml_path: Path) -> None:
53
+ super().__init__(settings_cls)
54
+ self._yaml_path = yaml_path
55
+
56
+ def get_field_value(self, field: Any, field_name: str) -> tuple[Any, str, bool]:
57
+ """Return the value for a given field from the YAML data."""
58
+ data = self._load_yaml()
59
+ value = data.get(field_name)
60
+ return value, field_name, False
61
+
62
+ def __call__(self) -> dict[str, Any]:
63
+ """Return all values from the YAML file."""
64
+ return self._load_yaml()
65
+
66
+ def _load_yaml(self) -> dict[str, Any]:
67
+ """Read and parse the YAML config file."""
68
+ if not self._yaml_path.exists():
69
+ return {}
70
+ try:
71
+ with self._yaml_path.open() as f:
72
+ data = yaml.safe_load(f)
73
+ return data if isinstance(data, dict) else {}
74
+ except (yaml.YAMLError, OSError) as exc:
75
+ raise ConfigError(f"Failed to read config file {self._yaml_path}: {exc}") from exc
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Pydantic Settings model
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ class Config(BaseSettings):
84
+ """User configuration for CloudShellGPT.
85
+
86
+ Settings are resolved in order of priority (highest first):
87
+ 1. Environment variables (prefix CSGPT_)
88
+ 2. YAML config file (~/.csgpt/config.yaml)
89
+ 3. Field defaults
90
+ """
91
+
92
+ region: str = Field(default="us-east-1", description="AWS region for Bedrock calls")
93
+ language: str = Field(default="auto", description="UI language (auto, en, es, pt, etc.)")
94
+ default_output: str = Field(
95
+ default="table", description="Output format: table, json, yaml, csv"
96
+ )
97
+ bedrock_model: str = Field(
98
+ default="us.anthropic.claude-sonnet-4-6",
99
+ description="Bedrock model ID for translations",
100
+ )
101
+ require_confirmation_for: list[str] = Field(
102
+ default_factory=lambda: ["high", "critical"],
103
+ description="Risk levels that require user confirmation",
104
+ )
105
+ enable_pii_detection: bool = Field(
106
+ default=False, description="Enable PII detection via Comprehend"
107
+ )
108
+ enable_cost_preview: bool = Field(
109
+ default=True, description="Show cost estimate before execution"
110
+ )
111
+ enable_learning_mode: bool = Field(
112
+ default=True, description="Show educational tips after execution"
113
+ )
114
+ max_cost_alert: int = Field(default=100, description="USD threshold for cost alert warnings")
115
+ timeout: int = Field(default=30, description="Command execution timeout in seconds")
116
+
117
+ model_config = {
118
+ "env_prefix": "CSGPT_",
119
+ "env_file": ".env",
120
+ "env_file_encoding": "utf-8",
121
+ "extra": "ignore",
122
+ }
123
+
124
+ @field_validator("default_output")
125
+ @classmethod
126
+ def validate_output_format(cls, v: str) -> str:
127
+ """Ensure default_output is one of the supported formats."""
128
+ allowed = {fmt.value for fmt in OutputFormat}
129
+ if v not in allowed:
130
+ msg = f"default_output must be one of {sorted(allowed)}, got '{v}'"
131
+ raise ValueError(msg)
132
+ return v
133
+
134
+ @field_validator("max_cost_alert")
135
+ @classmethod
136
+ def validate_max_cost_alert(cls, v: int) -> int:
137
+ """Ensure max_cost_alert is a positive value."""
138
+ if v < 0:
139
+ msg = f"max_cost_alert must be >= 0, got {v}"
140
+ raise ValueError(msg)
141
+ return v
142
+
143
+ @field_validator("timeout")
144
+ @classmethod
145
+ def validate_timeout(cls, v: int) -> int:
146
+ """Ensure timeout is a positive value (> 0)."""
147
+ if v <= 0:
148
+ msg = f"timeout must be > 0, got {v}"
149
+ raise ValueError(msg)
150
+ return v
151
+
152
+ @classmethod
153
+ def settings_customise_sources(
154
+ cls,
155
+ settings_cls: type[BaseSettings],
156
+ init_settings: PydanticBaseSettingsSource,
157
+ env_settings: PydanticBaseSettingsSource,
158
+ dotenv_settings: PydanticBaseSettingsSource,
159
+ file_secret_settings: PydanticBaseSettingsSource,
160
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
161
+ """Customise settings sources to include YAML file.
162
+
163
+ Priority order (highest first):
164
+ 1. init_settings (programmatic overrides)
165
+ 2. env_settings (CSGPT_* env vars)
166
+ 3. dotenv_settings (.env file)
167
+ 4. yaml_settings (~/.csgpt/config.yaml)
168
+ 5. file_secret_settings
169
+ """
170
+ return (
171
+ init_settings,
172
+ env_settings,
173
+ dotenv_settings,
174
+ YamlSettingsSource(settings_cls, CONFIG_FILE),
175
+ file_secret_settings,
176
+ )
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # ConfigManager
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ class ConfigManager:
185
+ """Manages the user's CloudShellGPT configuration.
186
+
187
+ Provides load, save, get, set, and reload operations over the
188
+ Config model backed by ~/.csgpt/config.yaml.
189
+ """
190
+
191
+ def __init__(self, config_path: Path | None = None) -> None:
192
+ """Initialize the ConfigManager.
193
+
194
+ Creates the config file with defaults if it doesn't exist.
195
+
196
+ Args:
197
+ config_path: Path to the YAML config file. Defaults to ~/.csgpt/config.yaml.
198
+ """
199
+ self.config_path = config_path or CONFIG_FILE
200
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
201
+ file_existed = self.config_path.exists()
202
+ self._config = self._load()
203
+ if not file_existed:
204
+ self.save()
205
+
206
+ @property
207
+ def config(self) -> Config:
208
+ """Return the current Config instance."""
209
+ return self._config
210
+
211
+ def get(self, key: str, default: Any = None) -> Any:
212
+ """Get a config value by key.
213
+
214
+ Args:
215
+ key: The configuration field name.
216
+ default: Fallback value if the key doesn't exist.
217
+
218
+ Returns:
219
+ The configuration value or the default.
220
+ """
221
+ return getattr(self._config, key, default)
222
+
223
+ def set(self, key: str, value: Any) -> None:
224
+ """Set a config value.
225
+
226
+ Args:
227
+ key: The configuration field name.
228
+ value: The new value to assign.
229
+
230
+ Raises:
231
+ ConfigError: If the key is not a valid config field or validation fails.
232
+ """
233
+ if key not in self._config.model_fields:
234
+ raise ConfigError(f"Unknown config key: '{key}'")
235
+ try:
236
+ data = self._config.model_dump()
237
+ data[key] = value
238
+ self._config = Config(**data)
239
+ except Exception as exc:
240
+ raise ConfigError(f"Invalid value for '{key}': {exc}") from exc
241
+
242
+ def save(self) -> None:
243
+ """Save the current config to disk.
244
+
245
+ Raises:
246
+ ConfigError: If writing to disk fails.
247
+ """
248
+ try:
249
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
250
+ with self.config_path.open("w") as f:
251
+ yaml.dump(self._config.model_dump(), f, default_flow_style=False)
252
+ except OSError as exc:
253
+ raise ConfigError(f"Failed to save config to {self.config_path}: {exc}") from exc
254
+
255
+ def reset_defaults(self) -> None:
256
+ """Reset configuration to defaults and save to disk.
257
+
258
+ Overwrites the current config file with all default values.
259
+ """
260
+ self._config = Config()
261
+ self.save()
262
+
263
+ def reload(self) -> None:
264
+ """Reload config from disk, picking up any external changes."""
265
+ self._config = self._load()
266
+
267
+ def to_yaml(self) -> str:
268
+ """Return the config as a YAML string.
269
+
270
+ Returns:
271
+ YAML-formatted string of the current configuration.
272
+ """
273
+ result: str = yaml.dump(self._config.model_dump(), default_flow_style=False)
274
+ return result
275
+
276
+ def _load(self) -> Config:
277
+ """Load config from YAML file merged with env vars and defaults.
278
+
279
+ Returns:
280
+ A validated Config instance.
281
+
282
+ Raises:
283
+ ConfigError: If the config file is malformed.
284
+ """
285
+ if not self.config_path.exists():
286
+ return Config()
287
+ try:
288
+ with self.config_path.open() as f:
289
+ data = yaml.safe_load(f) or {}
290
+ if not isinstance(data, dict):
291
+ raise ConfigError(f"Config file {self.config_path} must contain a YAML mapping")
292
+ return Config(**data)
293
+ except ConfigError:
294
+ raise
295
+ except Exception as exc:
296
+ raise ConfigError(f"Failed to load config from {self.config_path}: {exc}") from exc