bt-cli 0.4.13__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.
- bt_cli/__init__.py +3 -0
- bt_cli/cli.py +830 -0
- bt_cli/commands/__init__.py +1 -0
- bt_cli/commands/configure.py +415 -0
- bt_cli/commands/learn.py +229 -0
- bt_cli/commands/quick.py +784 -0
- bt_cli/core/__init__.py +1 -0
- bt_cli/core/auth.py +213 -0
- bt_cli/core/client.py +313 -0
- bt_cli/core/config.py +393 -0
- bt_cli/core/config_file.py +420 -0
- bt_cli/core/csv_utils.py +91 -0
- bt_cli/core/errors.py +247 -0
- bt_cli/core/output.py +205 -0
- bt_cli/core/prompts.py +87 -0
- bt_cli/core/rest_debug.py +221 -0
- bt_cli/data/CLAUDE.md +94 -0
- bt_cli/data/__init__.py +0 -0
- bt_cli/data/skills/bt/SKILL.md +108 -0
- bt_cli/data/skills/entitle/SKILL.md +170 -0
- bt_cli/data/skills/epmw/SKILL.md +144 -0
- bt_cli/data/skills/pra/SKILL.md +150 -0
- bt_cli/data/skills/pws/SKILL.md +198 -0
- bt_cli/entitle/__init__.py +1 -0
- bt_cli/entitle/client/__init__.py +5 -0
- bt_cli/entitle/client/base.py +443 -0
- bt_cli/entitle/commands/__init__.py +24 -0
- bt_cli/entitle/commands/accounts.py +53 -0
- bt_cli/entitle/commands/applications.py +39 -0
- bt_cli/entitle/commands/auth.py +68 -0
- bt_cli/entitle/commands/bundles.py +218 -0
- bt_cli/entitle/commands/integrations.py +60 -0
- bt_cli/entitle/commands/permissions.py +70 -0
- bt_cli/entitle/commands/policies.py +97 -0
- bt_cli/entitle/commands/resources.py +131 -0
- bt_cli/entitle/commands/roles.py +74 -0
- bt_cli/entitle/commands/users.py +123 -0
- bt_cli/entitle/commands/workflows.py +187 -0
- bt_cli/entitle/models/__init__.py +31 -0
- bt_cli/entitle/models/bundle.py +28 -0
- bt_cli/entitle/models/common.py +37 -0
- bt_cli/entitle/models/integration.py +30 -0
- bt_cli/entitle/models/permission.py +27 -0
- bt_cli/entitle/models/policy.py +25 -0
- bt_cli/entitle/models/resource.py +29 -0
- bt_cli/entitle/models/role.py +28 -0
- bt_cli/entitle/models/user.py +24 -0
- bt_cli/entitle/models/workflow.py +55 -0
- bt_cli/epmw/__init__.py +1 -0
- bt_cli/epmw/client/__init__.py +5 -0
- bt_cli/epmw/client/base.py +848 -0
- bt_cli/epmw/commands/__init__.py +33 -0
- bt_cli/epmw/commands/audits.py +250 -0
- bt_cli/epmw/commands/auth.py +55 -0
- bt_cli/epmw/commands/computers.py +140 -0
- bt_cli/epmw/commands/events.py +233 -0
- bt_cli/epmw/commands/groups.py +215 -0
- bt_cli/epmw/commands/policies.py +673 -0
- bt_cli/epmw/commands/quick.py +348 -0
- bt_cli/epmw/commands/requests.py +224 -0
- bt_cli/epmw/commands/roles.py +78 -0
- bt_cli/epmw/commands/tasks.py +38 -0
- bt_cli/epmw/commands/users.py +219 -0
- bt_cli/epmw/models/__init__.py +1 -0
- bt_cli/pra/__init__.py +1 -0
- bt_cli/pra/client/__init__.py +5 -0
- bt_cli/pra/client/base.py +618 -0
- bt_cli/pra/commands/__init__.py +30 -0
- bt_cli/pra/commands/auth.py +55 -0
- bt_cli/pra/commands/import_export.py +442 -0
- bt_cli/pra/commands/jump_clients.py +139 -0
- bt_cli/pra/commands/jump_groups.py +146 -0
- bt_cli/pra/commands/jump_items.py +638 -0
- bt_cli/pra/commands/jumpoints.py +95 -0
- bt_cli/pra/commands/policies.py +197 -0
- bt_cli/pra/commands/quick.py +470 -0
- bt_cli/pra/commands/teams.py +81 -0
- bt_cli/pra/commands/users.py +87 -0
- bt_cli/pra/commands/vault.py +564 -0
- bt_cli/pra/models/__init__.py +27 -0
- bt_cli/pra/models/common.py +12 -0
- bt_cli/pra/models/jump_client.py +25 -0
- bt_cli/pra/models/jump_group.py +15 -0
- bt_cli/pra/models/jump_item.py +72 -0
- bt_cli/pra/models/jumpoint.py +19 -0
- bt_cli/pra/models/team.py +14 -0
- bt_cli/pra/models/user.py +17 -0
- bt_cli/pra/models/vault.py +45 -0
- bt_cli/pws/__init__.py +1 -0
- bt_cli/pws/client/__init__.py +5 -0
- bt_cli/pws/client/base.py +356 -0
- bt_cli/pws/client/beyondinsight.py +869 -0
- bt_cli/pws/client/passwordsafe.py +1786 -0
- bt_cli/pws/commands/__init__.py +33 -0
- bt_cli/pws/commands/accounts.py +372 -0
- bt_cli/pws/commands/assets.py +311 -0
- bt_cli/pws/commands/auth.py +166 -0
- bt_cli/pws/commands/clouds.py +221 -0
- bt_cli/pws/commands/config.py +344 -0
- bt_cli/pws/commands/credentials.py +347 -0
- bt_cli/pws/commands/databases.py +306 -0
- bt_cli/pws/commands/directories.py +199 -0
- bt_cli/pws/commands/functional.py +298 -0
- bt_cli/pws/commands/import_export.py +452 -0
- bt_cli/pws/commands/platforms.py +118 -0
- bt_cli/pws/commands/quick.py +1646 -0
- bt_cli/pws/commands/search.py +256 -0
- bt_cli/pws/commands/secrets.py +1343 -0
- bt_cli/pws/commands/systems.py +389 -0
- bt_cli/pws/commands/users.py +415 -0
- bt_cli/pws/commands/workgroups.py +166 -0
- bt_cli/pws/config.py +18 -0
- bt_cli/pws/models/__init__.py +19 -0
- bt_cli/pws/models/account.py +186 -0
- bt_cli/pws/models/asset.py +102 -0
- bt_cli/pws/models/common.py +132 -0
- bt_cli/pws/models/system.py +121 -0
- bt_cli-0.4.13.dist-info/METADATA +417 -0
- bt_cli-0.4.13.dist-info/RECORD +121 -0
- bt_cli-0.4.13.dist-info/WHEEL +4 -0
- bt_cli-0.4.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""File-based configuration management for BeyondTrust CLI.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- YAML config file at ~/.bt-cli/config.yaml
|
|
5
|
+
- Multiple profiles (dev, production, etc.)
|
|
6
|
+
- Layered configuration (CLI flags > env vars > config file)
|
|
7
|
+
- Optional keyring integration for secrets
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Default config directory and file
|
|
21
|
+
CONFIG_DIR = Path.home() / ".bt-cli"
|
|
22
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
23
|
+
|
|
24
|
+
# Products and their configuration fields
|
|
25
|
+
PRODUCTS = {
|
|
26
|
+
"pws": {
|
|
27
|
+
"name": "Password Safe",
|
|
28
|
+
"fields": {
|
|
29
|
+
"api_url": {
|
|
30
|
+
"prompt": "API URL",
|
|
31
|
+
"required": True,
|
|
32
|
+
"secret": False,
|
|
33
|
+
"example": "https://your-server/BeyondTrust/api/public/v3",
|
|
34
|
+
},
|
|
35
|
+
"auth_method": {"prompt": "Auth method (oauth/apikey)", "required": True, "secret": False, "choices": ["oauth", "apikey"]},
|
|
36
|
+
"client_id": {"prompt": "Client ID", "required": False, "secret": False, "if": "auth_method == oauth"},
|
|
37
|
+
"client_secret": {"prompt": "Client Secret", "required": False, "secret": True, "if": "auth_method == oauth"},
|
|
38
|
+
"api_key": {"prompt": "API Key", "required": False, "secret": True, "if": "auth_method == apikey"},
|
|
39
|
+
"run_as": {"prompt": "Run As (impersonation username)", "required": False, "secret": False},
|
|
40
|
+
"verify_ssl": {"prompt": "Verify SSL", "required": False, "secret": False, "default": True},
|
|
41
|
+
"timeout": {"prompt": "Timeout (seconds)", "required": False, "secret": False, "default": 30},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
"entitle": {
|
|
45
|
+
"name": "Entitle",
|
|
46
|
+
"fields": {
|
|
47
|
+
"api_url": {
|
|
48
|
+
"prompt": "API URL",
|
|
49
|
+
"required": True,
|
|
50
|
+
"secret": False,
|
|
51
|
+
"default": "https://api.us.entitle.io",
|
|
52
|
+
"example": "https://api.us.entitle.io or https://api.eu.entitle.io",
|
|
53
|
+
},
|
|
54
|
+
"api_key": {"prompt": "API Key", "required": True, "secret": True},
|
|
55
|
+
"verify_ssl": {"prompt": "Verify SSL", "required": False, "secret": False, "default": True},
|
|
56
|
+
"timeout": {"prompt": "Timeout (seconds)", "required": False, "secret": False, "default": 30},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
"pra": {
|
|
60
|
+
"name": "Privileged Remote Access",
|
|
61
|
+
"fields": {
|
|
62
|
+
"api_url": {
|
|
63
|
+
"prompt": "API URL",
|
|
64
|
+
"required": True,
|
|
65
|
+
"secret": False,
|
|
66
|
+
"example": "https://your-site.beyondtrustcloud.com",
|
|
67
|
+
},
|
|
68
|
+
"client_id": {"prompt": "Client ID", "required": True, "secret": False},
|
|
69
|
+
"client_secret": {"prompt": "Client Secret", "required": True, "secret": True},
|
|
70
|
+
"verify_ssl": {"prompt": "Verify SSL", "required": False, "secret": False, "default": True},
|
|
71
|
+
"timeout": {"prompt": "Timeout (seconds)", "required": False, "secret": False, "default": 30},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
"epmw": {
|
|
75
|
+
"name": "EPM Windows",
|
|
76
|
+
"fields": {
|
|
77
|
+
"api_url": {
|
|
78
|
+
"prompt": "API URL",
|
|
79
|
+
"required": True,
|
|
80
|
+
"secret": False,
|
|
81
|
+
"example": "https://your-site-services.epm.bt3ng.com",
|
|
82
|
+
},
|
|
83
|
+
"client_id": {"prompt": "Client ID", "required": True, "secret": False},
|
|
84
|
+
"client_secret": {"prompt": "Client Secret", "required": True, "secret": True},
|
|
85
|
+
"verify_ssl": {"prompt": "Verify SSL", "required": False, "secret": False, "default": True},
|
|
86
|
+
"timeout": {"prompt": "Timeout (seconds)", "required": False, "secret": False, "default": 30},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ConfigFile:
|
|
94
|
+
"""Represents the config file structure."""
|
|
95
|
+
|
|
96
|
+
default_profile: str = "default"
|
|
97
|
+
profiles: dict[str, dict[str, dict[str, Any]]] = field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
def get_product_config(self, product: str, profile: Optional[str] = None) -> dict[str, Any]:
|
|
100
|
+
"""Get configuration for a product from a profile.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
product: Product name (pws, entitle, pra, epmw)
|
|
104
|
+
profile: Profile name, uses default_profile if not specified
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Dictionary of configuration values
|
|
108
|
+
"""
|
|
109
|
+
profile = profile or self.default_profile
|
|
110
|
+
if profile not in self.profiles:
|
|
111
|
+
return {}
|
|
112
|
+
return self.profiles[profile].get(product, {})
|
|
113
|
+
|
|
114
|
+
def set_product_config(
|
|
115
|
+
self,
|
|
116
|
+
product: str,
|
|
117
|
+
config: dict[str, Any],
|
|
118
|
+
profile: Optional[str] = None
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Set configuration for a product in a profile.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
product: Product name
|
|
124
|
+
config: Configuration dictionary
|
|
125
|
+
profile: Profile name, uses default_profile if not specified
|
|
126
|
+
"""
|
|
127
|
+
profile = profile or self.default_profile
|
|
128
|
+
if profile not in self.profiles:
|
|
129
|
+
self.profiles[profile] = {}
|
|
130
|
+
self.profiles[profile][product] = config
|
|
131
|
+
|
|
132
|
+
def list_profiles(self) -> list[str]:
|
|
133
|
+
"""List all available profiles."""
|
|
134
|
+
return list(self.profiles.keys())
|
|
135
|
+
|
|
136
|
+
def delete_profile(self, profile: str) -> bool:
|
|
137
|
+
"""Delete a profile.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
profile: Profile name to delete
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
True if deleted, False if not found
|
|
144
|
+
"""
|
|
145
|
+
if profile in self.profiles:
|
|
146
|
+
del self.profiles[profile]
|
|
147
|
+
if self.default_profile == profile:
|
|
148
|
+
self.default_profile = "default"
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def ensure_config_dir() -> Path:
|
|
154
|
+
"""Ensure the config directory exists with proper permissions."""
|
|
155
|
+
CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
156
|
+
return CONFIG_DIR
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_config_file(path: Optional[Path] = None) -> ConfigFile:
|
|
160
|
+
"""Load configuration from YAML file.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
path: Optional path to config file, uses default if not specified
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
ConfigFile object (empty if file doesn't exist)
|
|
167
|
+
"""
|
|
168
|
+
path = path or CONFIG_FILE
|
|
169
|
+
|
|
170
|
+
if not path.exists():
|
|
171
|
+
return ConfigFile()
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
with open(path, "r") as f:
|
|
175
|
+
data = yaml.safe_load(f) or {}
|
|
176
|
+
|
|
177
|
+
return ConfigFile(
|
|
178
|
+
default_profile=data.get("default_profile", "default"),
|
|
179
|
+
profiles=data.get("profiles", {}),
|
|
180
|
+
)
|
|
181
|
+
except (yaml.YAMLError, OSError) as e:
|
|
182
|
+
# Return empty config on error, caller can handle
|
|
183
|
+
return ConfigFile()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def save_config_file(config: ConfigFile, path: Optional[Path] = None) -> None:
|
|
187
|
+
"""Save configuration to YAML file.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
config: ConfigFile object to save
|
|
191
|
+
path: Optional path to config file, uses default if not specified
|
|
192
|
+
|
|
193
|
+
Security: Uses atomic file creation with 0o600 permissions to prevent
|
|
194
|
+
race conditions where the file could be readable by others.
|
|
195
|
+
"""
|
|
196
|
+
path = path or CONFIG_FILE
|
|
197
|
+
ensure_config_dir()
|
|
198
|
+
|
|
199
|
+
data = {
|
|
200
|
+
"default_profile": config.default_profile,
|
|
201
|
+
"profiles": config.profiles,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Security: Create file atomically with secure permissions (0o600)
|
|
205
|
+
# This prevents TOCTOU race where file could be readable between
|
|
206
|
+
# creation and chmod.
|
|
207
|
+
import os
|
|
208
|
+
import tempfile
|
|
209
|
+
|
|
210
|
+
# Write to temp file in same directory, then atomic rename
|
|
211
|
+
dir_path = path.parent
|
|
212
|
+
try:
|
|
213
|
+
# Create temp file with secure permissions
|
|
214
|
+
fd, tmp_path = tempfile.mkstemp(dir=dir_path, prefix=".config_", suffix=".tmp")
|
|
215
|
+
try:
|
|
216
|
+
# Set permissions on file descriptor before writing
|
|
217
|
+
os.fchmod(fd, 0o600)
|
|
218
|
+
with os.fdopen(fd, "w") as f:
|
|
219
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
220
|
+
# Atomic rename
|
|
221
|
+
os.rename(tmp_path, path)
|
|
222
|
+
except Exception:
|
|
223
|
+
# Clean up temp file on error
|
|
224
|
+
try:
|
|
225
|
+
os.unlink(tmp_path)
|
|
226
|
+
except OSError:
|
|
227
|
+
pass
|
|
228
|
+
raise
|
|
229
|
+
except OSError as e:
|
|
230
|
+
logger.error(f"Failed to save config file: {e}")
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_layered_config(
|
|
235
|
+
product: str,
|
|
236
|
+
profile: Optional[str] = None,
|
|
237
|
+
cli_overrides: Optional[dict[str, Any]] = None,
|
|
238
|
+
) -> dict[str, Any]:
|
|
239
|
+
"""Get configuration with layered precedence.
|
|
240
|
+
|
|
241
|
+
Precedence (highest to lowest):
|
|
242
|
+
1. CLI flags (cli_overrides)
|
|
243
|
+
2. Environment variables
|
|
244
|
+
3. Config file
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
product: Product name (pws, entitle, pra, epmw)
|
|
248
|
+
profile: Profile name, uses default if not specified
|
|
249
|
+
cli_overrides: Dictionary of CLI flag overrides
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Merged configuration dictionary
|
|
253
|
+
"""
|
|
254
|
+
# Start with config file values
|
|
255
|
+
config_file = load_config_file()
|
|
256
|
+
config = config_file.get_product_config(product, profile)
|
|
257
|
+
|
|
258
|
+
# Layer environment variables
|
|
259
|
+
env_prefix = _get_env_prefix(product)
|
|
260
|
+
env_mappings = _get_env_mappings(product)
|
|
261
|
+
|
|
262
|
+
for config_key, env_var in env_mappings.items():
|
|
263
|
+
env_value = os.getenv(env_var)
|
|
264
|
+
if env_value is not None:
|
|
265
|
+
config[config_key] = _parse_env_value(env_value, config_key)
|
|
266
|
+
|
|
267
|
+
# Layer CLI overrides (highest precedence)
|
|
268
|
+
if cli_overrides:
|
|
269
|
+
for key, value in cli_overrides.items():
|
|
270
|
+
if value is not None:
|
|
271
|
+
config[key] = value
|
|
272
|
+
|
|
273
|
+
return config
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _get_env_prefix(product: str) -> str:
|
|
277
|
+
"""Get environment variable prefix for a product."""
|
|
278
|
+
prefixes = {
|
|
279
|
+
"pws": "BT_PWS",
|
|
280
|
+
"entitle": "BT_ENTITLE",
|
|
281
|
+
"pra": "BT_PRA",
|
|
282
|
+
"epmw": "BT_EPM",
|
|
283
|
+
}
|
|
284
|
+
return prefixes.get(product, f"BT_{product.upper()}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _get_env_mappings(product: str) -> dict[str, str]:
|
|
288
|
+
"""Get mapping of config keys to environment variable names."""
|
|
289
|
+
prefix = _get_env_prefix(product)
|
|
290
|
+
|
|
291
|
+
# Common mappings
|
|
292
|
+
mappings = {
|
|
293
|
+
"api_url": f"{prefix}_API_URL",
|
|
294
|
+
"api_key": f"{prefix}_API_KEY",
|
|
295
|
+
"client_id": f"{prefix}_CLIENT_ID",
|
|
296
|
+
"client_secret": f"{prefix}_CLIENT_SECRET",
|
|
297
|
+
"verify_ssl": f"{prefix}_VERIFY_SSL",
|
|
298
|
+
"timeout": f"{prefix}_TIMEOUT",
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
# Product-specific additions
|
|
302
|
+
if product == "pws":
|
|
303
|
+
mappings["run_as"] = f"{prefix}_RUN_AS"
|
|
304
|
+
mappings["api_version"] = f"{prefix}_API_VERSION"
|
|
305
|
+
|
|
306
|
+
return mappings
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _parse_env_value(value: str, key: str) -> Any:
|
|
310
|
+
"""Parse environment variable value to appropriate type."""
|
|
311
|
+
# Boolean fields
|
|
312
|
+
if key in ("verify_ssl",):
|
|
313
|
+
return value.lower() not in ("false", "0", "no", "off")
|
|
314
|
+
|
|
315
|
+
# Numeric fields
|
|
316
|
+
if key in ("timeout",):
|
|
317
|
+
try:
|
|
318
|
+
return float(value)
|
|
319
|
+
except ValueError:
|
|
320
|
+
return 30.0
|
|
321
|
+
|
|
322
|
+
return value
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# Keyring support (optional)
|
|
326
|
+
_keyring_warning_shown = False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _keyring_available() -> bool:
|
|
330
|
+
"""Check if keyring is available."""
|
|
331
|
+
try:
|
|
332
|
+
import keyring
|
|
333
|
+
return True
|
|
334
|
+
except ImportError:
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _warn_keyring_unavailable() -> None:
|
|
339
|
+
"""Warn user that keyring is unavailable and secrets stored in plaintext.
|
|
340
|
+
|
|
341
|
+
Only shows once per process.
|
|
342
|
+
"""
|
|
343
|
+
global _keyring_warning_shown
|
|
344
|
+
if _keyring_warning_shown:
|
|
345
|
+
return
|
|
346
|
+
_keyring_warning_shown = True
|
|
347
|
+
|
|
348
|
+
import sys
|
|
349
|
+
print(
|
|
350
|
+
"\033[93mWarning: Keyring not available. Secrets stored in plaintext config file.\n"
|
|
351
|
+
"Install keyring package for secure storage: pip install keyring\033[0m",
|
|
352
|
+
file=sys.stderr,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def get_secret_from_keyring(service: str, key: str) -> Optional[str]:
|
|
357
|
+
"""Get a secret from the system keyring.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
service: Service name (e.g., "bt-cli")
|
|
361
|
+
key: Key name (e.g., "pws-default-client_secret")
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Secret value or None if not found/available
|
|
365
|
+
"""
|
|
366
|
+
if not _keyring_available():
|
|
367
|
+
_warn_keyring_unavailable()
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
import keyring
|
|
372
|
+
return keyring.get_password(service, key)
|
|
373
|
+
except Exception as e:
|
|
374
|
+
logger.warning(f"Failed to retrieve secret '{key}' from keyring: {e}")
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def set_secret_in_keyring(service: str, key: str, value: str) -> bool:
|
|
379
|
+
"""Store a secret in the system keyring.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
service: Service name
|
|
383
|
+
key: Key name
|
|
384
|
+
value: Secret value
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
True if stored successfully, False otherwise
|
|
388
|
+
"""
|
|
389
|
+
if not _keyring_available():
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
import keyring
|
|
394
|
+
keyring.set_password(service, key, value)
|
|
395
|
+
return True
|
|
396
|
+
except Exception as e:
|
|
397
|
+
logger.warning(f"Failed to store secret '{key}' in keyring: {e}")
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def delete_secret_from_keyring(service: str, key: str) -> bool:
|
|
402
|
+
"""Delete a secret from the system keyring.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
service: Service name
|
|
406
|
+
key: Key name
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
True if deleted successfully, False otherwise
|
|
410
|
+
"""
|
|
411
|
+
if not _keyring_available():
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
import keyring
|
|
416
|
+
keyring.delete_password(service, key)
|
|
417
|
+
return True
|
|
418
|
+
except Exception as e:
|
|
419
|
+
logger.warning(f"Failed to delete secret '{key}' from keyring: {e}")
|
|
420
|
+
return False
|
bt_cli/core/csv_utils.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Shared CSV utilities for import/export commands."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_csv(file_path: str) -> list[dict[str, Any]]:
|
|
9
|
+
"""Read CSV file and return list of dicts.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
file_path: Path to CSV file
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
List of dictionaries, one per row
|
|
16
|
+
"""
|
|
17
|
+
path = Path(file_path)
|
|
18
|
+
if not path.exists():
|
|
19
|
+
raise FileNotFoundError(f"CSV file not found: {file_path}")
|
|
20
|
+
|
|
21
|
+
with open(path, newline='', encoding='utf-8') as f:
|
|
22
|
+
reader = csv.DictReader(f)
|
|
23
|
+
return list(reader)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def write_csv(file_path: str, rows: list[dict[str, Any]], fieldnames: list[str]) -> None:
|
|
27
|
+
"""Write list of dicts to CSV file.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
file_path: Output path
|
|
31
|
+
rows: List of dictionaries to write
|
|
32
|
+
fieldnames: Column headers in order
|
|
33
|
+
"""
|
|
34
|
+
path = Path(file_path)
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
with open(path, 'w', newline='', encoding='utf-8') as f:
|
|
38
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore')
|
|
39
|
+
writer.writeheader()
|
|
40
|
+
writer.writerows(rows)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_required_fields(row: dict[str, Any], required: list[str], row_num: int) -> list[str]:
|
|
44
|
+
"""Validate that required fields are present and non-empty.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
row: Row dictionary
|
|
48
|
+
required: List of required field names
|
|
49
|
+
row_num: Row number for error messages
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
List of error messages (empty if valid)
|
|
53
|
+
"""
|
|
54
|
+
errors = []
|
|
55
|
+
for field in required:
|
|
56
|
+
value = row.get(field, "").strip()
|
|
57
|
+
if not value:
|
|
58
|
+
errors.append(f"Row {row_num}: Missing required field '{field}'")
|
|
59
|
+
return errors
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_bool(value: str) -> bool:
|
|
63
|
+
"""Parse boolean from CSV string.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
value: String value like 'true', 'false', 'yes', 'no', '1', '0'
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Boolean value
|
|
70
|
+
"""
|
|
71
|
+
if not value:
|
|
72
|
+
return False
|
|
73
|
+
return value.lower().strip() in ('true', 'yes', '1', 'y')
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_int(value: str, default: Optional[int] = None) -> Optional[int]:
|
|
77
|
+
"""Parse integer from CSV string.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
value: String value
|
|
81
|
+
default: Default if empty or invalid
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Integer value or default
|
|
85
|
+
"""
|
|
86
|
+
if not value or not value.strip():
|
|
87
|
+
return default
|
|
88
|
+
try:
|
|
89
|
+
return int(value.strip())
|
|
90
|
+
except ValueError:
|
|
91
|
+
return default
|