dbtk 0.8.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.
- dbtk/__init__.py +54 -0
- dbtk/cli.py +169 -0
- dbtk/config.py +1194 -0
- dbtk/cursors.py +566 -0
- dbtk/database.py +959 -0
- dbtk/dbtk_sample.yml +90 -0
- dbtk/defaults.py +29 -0
- dbtk/etl/__init__.py +61 -0
- dbtk/etl/base_surge.py +257 -0
- dbtk/etl/bulk_surge.py +783 -0
- dbtk/etl/config_generators.py +458 -0
- dbtk/etl/data_surge.py +294 -0
- dbtk/etl/managers.py +734 -0
- dbtk/etl/table.py +1215 -0
- dbtk/etl/transforms/__init__.py +107 -0
- dbtk/etl/transforms/address.py +560 -0
- dbtk/etl/transforms/core.py +594 -0
- dbtk/etl/transforms/database.py +506 -0
- dbtk/etl/transforms/datetime.py +497 -0
- dbtk/etl/transforms/email.py +72 -0
- dbtk/etl/transforms/phone.py +670 -0
- dbtk/formats/__init__.py +18 -0
- dbtk/formats/edi.py +182 -0
- dbtk/logging_utils.py +310 -0
- dbtk/readers/__init__.py +26 -0
- dbtk/readers/base.py +644 -0
- dbtk/readers/csv.py +195 -0
- dbtk/readers/data_frame.py +119 -0
- dbtk/readers/excel.py +260 -0
- dbtk/readers/fixed_width.py +359 -0
- dbtk/readers/json.py +323 -0
- dbtk/readers/utils.py +394 -0
- dbtk/readers/xml.py +260 -0
- dbtk/record.py +710 -0
- dbtk/utils.py +537 -0
- dbtk/writers/__init__.py +46 -0
- dbtk/writers/base.py +718 -0
- dbtk/writers/csv.py +107 -0
- dbtk/writers/database.py +158 -0
- dbtk/writers/excel.py +1086 -0
- dbtk/writers/fixed_width.py +290 -0
- dbtk/writers/json.py +156 -0
- dbtk/writers/utils.py +41 -0
- dbtk/writers/xml.py +387 -0
- dbtk-0.8.0.dist-info/METADATA +305 -0
- dbtk-0.8.0.dist-info/RECORD +50 -0
- dbtk-0.8.0.dist-info/WHEEL +5 -0
- dbtk-0.8.0.dist-info/entry_points.txt +2 -0
- dbtk-0.8.0.dist-info/licenses/LICENSE.txt +7 -0
- dbtk-0.8.0.dist-info/top_level.txt +1 -0
dbtk/config.py
ADDED
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
# dbtk/config.py
|
|
2
|
+
"""
|
|
3
|
+
Configuration management for database connections.
|
|
4
|
+
Supports YAML configuration files with optional password encryption and global settings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from textwrap import dedent
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Any, Optional, List, Tuple, Union
|
|
12
|
+
from cryptography.fernet import Fernet
|
|
13
|
+
from .defaults import settings # noqa: F401
|
|
14
|
+
from .database import Database, _get_params_for_database
|
|
15
|
+
from .cursors import Cursor
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import yaml
|
|
19
|
+
except ImportError:
|
|
20
|
+
raise ImportError("PyYAML is required. Install with: pip install PyYAML")
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import keyring
|
|
24
|
+
HAS_KEYRING = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
HAS_KEYRING = False
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _expand_env_var(value: str) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Expand environment variable reference with optional default.
|
|
34
|
+
|
|
35
|
+
Syntax:
|
|
36
|
+
${VAR_NAME} - required, raises ValueError if not set
|
|
37
|
+
${VAR_NAME:default} - use default if VAR_NAME not set
|
|
38
|
+
${VAR_NAME:} - use empty string if VAR_NAME not set
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
value: String potentially containing ${VAR} or ${VAR:default}
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Expanded value, or original value if not an env var reference
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If env var not set and no default provided
|
|
48
|
+
"""
|
|
49
|
+
if not (isinstance(value, str) and value.startswith('${') and value.endswith('}')):
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
content = value[2:-1] # Strip ${ and }
|
|
53
|
+
|
|
54
|
+
if ':' in content:
|
|
55
|
+
var_name, default = content.split(':', 1)
|
|
56
|
+
return os.environ.get(var_name, default)
|
|
57
|
+
else:
|
|
58
|
+
result = os.environ.get(content)
|
|
59
|
+
if result is None:
|
|
60
|
+
raise ValueError(f"Environment variable {content} not set")
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ensure_sample_config():
|
|
65
|
+
"""Copy sample config to ~/.config if no config exists and sample doesn't exist there."""
|
|
66
|
+
import shutil
|
|
67
|
+
|
|
68
|
+
# Define paths
|
|
69
|
+
user_config_dir = Path.home() / '.config'
|
|
70
|
+
user_config_file = user_config_dir / 'dbtk.yml'
|
|
71
|
+
|
|
72
|
+
# If user config already exists, nothing to do
|
|
73
|
+
if user_config_file.exists():
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Find sample config in package
|
|
77
|
+
package_dir = Path(__file__).parent
|
|
78
|
+
sample_config = package_dir / 'dbtk_sample.yml'
|
|
79
|
+
sample_target = user_config_dir / 'dbtk_sample.yml'
|
|
80
|
+
|
|
81
|
+
if not sample_config.exists():
|
|
82
|
+
return # No sample to copy
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
# Create ~/.config if it doesn't exist
|
|
86
|
+
user_config_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
# Copy sample config
|
|
89
|
+
shutil.copy(sample_config, sample_target)
|
|
90
|
+
logger.info(f"Created sample config at {sample_target}")
|
|
91
|
+
import sys
|
|
92
|
+
print(f"Created sample DBTK config at {sample_target}", file=sys.stderr)
|
|
93
|
+
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.debug(f"Could not create sample config: {e}")
|
|
96
|
+
# Don't fail - just continue without config
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def diagnose_config(config_file: Optional[str] = None) -> List[Tuple[str, str]]:
|
|
100
|
+
"""Full config health check using a real ConfigManager instance."""
|
|
101
|
+
results = []
|
|
102
|
+
|
|
103
|
+
# 1. Spin up a REAL manager — handles path, sample creation, everything
|
|
104
|
+
try:
|
|
105
|
+
mgr = ConfigManager(config_file)
|
|
106
|
+
results.append(('✓', f"Config loaded: {mgr.config_file}"))
|
|
107
|
+
except Exception as e:
|
|
108
|
+
results.append(('✗', f"Config failed: {e}"))
|
|
109
|
+
return results
|
|
110
|
+
|
|
111
|
+
# 2. Deps
|
|
112
|
+
results.append(('✓', "keyring ready") if HAS_KEYRING else ('?', "keyring optional"))
|
|
113
|
+
|
|
114
|
+
# 3. Keys — safe peek, no decrypt
|
|
115
|
+
env_key = os.getenv('DBTK_ENCRYPTION_KEY')
|
|
116
|
+
keyring_key = keyring.get_password('dbtk', 'encryption_key') if HAS_KEYRING else None
|
|
117
|
+
|
|
118
|
+
if env_key:
|
|
119
|
+
results.append(('✓', "DBTK_ENCRYPTION_KEY set"))
|
|
120
|
+
results.append(('✓', "Env key valid") if _valid_fernet(env_key) else ('✗', "Env key invalid"))
|
|
121
|
+
else:
|
|
122
|
+
results.append(('?', "No env key"))
|
|
123
|
+
|
|
124
|
+
if keyring_key:
|
|
125
|
+
results.append(('✓', "Keyring key set"))
|
|
126
|
+
results.append(('✓', "Keyring key valid") if _valid_fernet(keyring_key) else ('✗', "Keyring key invalid"))
|
|
127
|
+
elif HAS_KEYRING:
|
|
128
|
+
results.append(('?', "Keyring empty"))
|
|
129
|
+
|
|
130
|
+
if env_key and keyring_key:
|
|
131
|
+
results.append(('✓', "Keys match") if env_key == keyring_key else ('✗', "KEYS MISMATCH"))
|
|
132
|
+
|
|
133
|
+
# 4. Encrypted passwords? — ask the manager, not the file
|
|
134
|
+
enc_count = sum(
|
|
135
|
+
1 for c in mgr.config.get('connections', {}).values()
|
|
136
|
+
if 'encrypted_password' in c
|
|
137
|
+
) + sum(
|
|
138
|
+
1 for p in mgr.config.get('passwords', {}).values()
|
|
139
|
+
if 'encrypted_password' in p
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
results.append(('✓', f"{enc_count} encrypted passwords") if enc_count else ('✓', "No encrypted passwords"))
|
|
143
|
+
|
|
144
|
+
# 5. Unencrypted passwords
|
|
145
|
+
uenc_count = sum(
|
|
146
|
+
1 for c in mgr.config.get('connections', {}).values()
|
|
147
|
+
if 'password' in c and not(c.get('password', '').startswith('${'))
|
|
148
|
+
) + sum(
|
|
149
|
+
1 for p in mgr.config.get('passwords', {}).values()
|
|
150
|
+
if 'password' in p and not(p.get('password', '').startswith('${'))
|
|
151
|
+
)
|
|
152
|
+
results.append(("✗", f"{uenc_count} unencrypted passwords! (run `dbtk encrypt-config` to fix)") \
|
|
153
|
+
if uenc_count else ('✓', "No unencrypted passwords"))
|
|
154
|
+
return results
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _valid_fernet(key: str) -> bool:
|
|
158
|
+
try:
|
|
159
|
+
Fernet(key.encode())
|
|
160
|
+
return True
|
|
161
|
+
except Exception:
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ConfigManager:
|
|
166
|
+
"""
|
|
167
|
+
Manage DBTK configuration from YAML files.
|
|
168
|
+
|
|
169
|
+
ConfigManager handles loading and parsing YAML configuration files that define
|
|
170
|
+
database connections, encrypted passwords, and global settings. It searches for
|
|
171
|
+
configuration files in standard locations, validates the structure, and provides
|
|
172
|
+
methods for accessing connections and passwords.
|
|
173
|
+
|
|
174
|
+
The manager supports encrypted passwords using Fernet symmetric encryption,
|
|
175
|
+
environment variable substitution, and automatic sample config generation for
|
|
176
|
+
new users.
|
|
177
|
+
|
|
178
|
+
Configuration File Structure
|
|
179
|
+
----------------------------
|
|
180
|
+
::
|
|
181
|
+
|
|
182
|
+
# dbtk.yml
|
|
183
|
+
settings:
|
|
184
|
+
default_timezone: UTC
|
|
185
|
+
default_country: US
|
|
186
|
+
default_paramstyle: named
|
|
187
|
+
|
|
188
|
+
connections:
|
|
189
|
+
my_db:
|
|
190
|
+
type: postgres
|
|
191
|
+
host: localhost
|
|
192
|
+
database: myapp
|
|
193
|
+
user: admin
|
|
194
|
+
encrypted_password: gAAAAABh...
|
|
195
|
+
|
|
196
|
+
passwords:
|
|
197
|
+
api_key:
|
|
198
|
+
encrypted_password: gAAAAABh...
|
|
199
|
+
description: API key for external service
|
|
200
|
+
|
|
201
|
+
Configuration Locations
|
|
202
|
+
-----------------------
|
|
203
|
+
ConfigManager searches for configuration files in this order:
|
|
204
|
+
|
|
205
|
+
1. File specified in config_file parameter
|
|
206
|
+
2. ``./dbtk.yml`` (current directory)
|
|
207
|
+
3. ``./dbtk.yaml`` (current directory)
|
|
208
|
+
4. ``~/.config/dbtk.yml`` (user config directory)
|
|
209
|
+
5. ``~/.config/dbtk.yaml`` (user config directory)
|
|
210
|
+
|
|
211
|
+
If no config is found, creates a sample config at ``~/.config/dbtk.yml``.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
config_file : str or Path, optional
|
|
216
|
+
Path to YAML config file. If None, searches standard locations.
|
|
217
|
+
|
|
218
|
+
Attributes
|
|
219
|
+
----------
|
|
220
|
+
config_file : Path
|
|
221
|
+
Path to the loaded configuration file
|
|
222
|
+
config : dict
|
|
223
|
+
Parsed configuration dictionary
|
|
224
|
+
|
|
225
|
+
Example
|
|
226
|
+
-------
|
|
227
|
+
::
|
|
228
|
+
|
|
229
|
+
from dbtk.config import ConfigManager
|
|
230
|
+
|
|
231
|
+
# Load from default location
|
|
232
|
+
config_mgr = ConfigManager()
|
|
233
|
+
|
|
234
|
+
# Access connection settings
|
|
235
|
+
conn_params = config_mgr.get_connection('production_db')
|
|
236
|
+
|
|
237
|
+
# Get encrypted password
|
|
238
|
+
api_key = config_mgr.get_password('external_api')
|
|
239
|
+
|
|
240
|
+
# Load specific config file
|
|
241
|
+
config_mgr = ConfigManager('/path/to/custom.yml')
|
|
242
|
+
|
|
243
|
+
See Also
|
|
244
|
+
--------
|
|
245
|
+
dbtk.connect : Connect to database using config
|
|
246
|
+
generate_encryption_key : Create encryption key for passwords
|
|
247
|
+
encrypt_config_file : Encrypt passwords in config file
|
|
248
|
+
|
|
249
|
+
Notes
|
|
250
|
+
-----
|
|
251
|
+
* YAML files must have .yml or .yaml extension
|
|
252
|
+
* Connections require 'type' field (postgres, oracle, mysql, etc.)
|
|
253
|
+
* Encrypted passwords require DBTK_ENCRYPTION_KEY environment variable
|
|
254
|
+
* Environment variables can be used with ${VAR_NAME} syntax
|
|
255
|
+
* Sample config is created at ~/.config/dbtk.yml on first run if no config exists
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def __init__(self, config_file: Optional[Union[str, Path]] = None):
|
|
259
|
+
"""
|
|
260
|
+
Initialize config manager and load configuration.
|
|
261
|
+
|
|
262
|
+
Parameters
|
|
263
|
+
----------
|
|
264
|
+
config_file : str or Path, optional
|
|
265
|
+
Path to YAML config file. If None, searches for:
|
|
266
|
+
|
|
267
|
+
* ``dbtk.yml`` in current directory
|
|
268
|
+
* ``dbtk.yaml`` in current directory
|
|
269
|
+
* ``~/.config/dbtk.yml``
|
|
270
|
+
* ``~/.config/dbtk.yaml``
|
|
271
|
+
|
|
272
|
+
Raises
|
|
273
|
+
------
|
|
274
|
+
FileNotFoundError
|
|
275
|
+
If no config file found in any search location
|
|
276
|
+
ValueError
|
|
277
|
+
If config file is invalid or malformed
|
|
278
|
+
|
|
279
|
+
Example
|
|
280
|
+
-------
|
|
281
|
+
::
|
|
282
|
+
|
|
283
|
+
# Use default config location
|
|
284
|
+
config = ConfigManager()
|
|
285
|
+
|
|
286
|
+
# Use specific config file
|
|
287
|
+
config = ConfigManager('/etc/dbtk/production.yml')
|
|
288
|
+
|
|
289
|
+
# Config auto-creates sample if none exists
|
|
290
|
+
config = ConfigManager() # Creates ~/.config/dbtk.yml if needed
|
|
291
|
+
"""
|
|
292
|
+
self.config_file = self._find_config_file(config_file)
|
|
293
|
+
self.config = self._load_config()
|
|
294
|
+
self._fernet = None
|
|
295
|
+
|
|
296
|
+
# Apply global settings
|
|
297
|
+
self._apply_settings()
|
|
298
|
+
|
|
299
|
+
def _find_config_file(self, config_file: Optional[Union[str, Path]]) -> Path:
|
|
300
|
+
"""Find the configuration file."""
|
|
301
|
+
if config_file:
|
|
302
|
+
path = Path(config_file)
|
|
303
|
+
if not path.exists():
|
|
304
|
+
raise FileNotFoundError(f"Config file not found: {config_file}")
|
|
305
|
+
return path
|
|
306
|
+
|
|
307
|
+
# Look for default locations
|
|
308
|
+
candidates = [
|
|
309
|
+
Path("dbtk.yml"),
|
|
310
|
+
Path("dbtk.yaml"),
|
|
311
|
+
Path.home() / ".config" / "dbtk.yml",
|
|
312
|
+
Path.home() / ".config" / "dbtk.yaml"
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
for candidate in candidates:
|
|
316
|
+
if candidate.exists():
|
|
317
|
+
return candidate
|
|
318
|
+
|
|
319
|
+
# No config found - try to create sample config
|
|
320
|
+
_ensure_sample_config()
|
|
321
|
+
|
|
322
|
+
# Check again after creating sample
|
|
323
|
+
for candidate in candidates:
|
|
324
|
+
if candidate.exists():
|
|
325
|
+
return candidate
|
|
326
|
+
|
|
327
|
+
raise FileNotFoundError(
|
|
328
|
+
"No config file found. Looked in: " +
|
|
329
|
+
", ".join(str(c) for c in candidates)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def _load_config(self) -> Dict[str, Any]:
|
|
333
|
+
"""Load and validate configuration file."""
|
|
334
|
+
try:
|
|
335
|
+
with open(self.config_file, 'r') as f:
|
|
336
|
+
config = yaml.safe_load(f) or {}
|
|
337
|
+
if not isinstance(config, dict):
|
|
338
|
+
raise ValueError(f"Invalid config file {self.config_file}.")
|
|
339
|
+
|
|
340
|
+
# Validate connections section if it exists
|
|
341
|
+
if 'connections' in config:
|
|
342
|
+
for name, conn in config.get('connections', {}).items():
|
|
343
|
+
if not isinstance(conn, dict) or ('type' not in conn and 'driver' not in conn):
|
|
344
|
+
raise ValueError(f"Invalid connection '{name}' in {self.config_file}: 'type' or 'driver' is required")
|
|
345
|
+
|
|
346
|
+
# Validate passwords section if it exists
|
|
347
|
+
if 'passwords' in config:
|
|
348
|
+
if not isinstance(config['passwords'], dict):
|
|
349
|
+
raise ValueError(f"Invalid config file {self.config_file}: 'passwords' must be a dictionary")
|
|
350
|
+
for name, password_data in config['passwords'].items():
|
|
351
|
+
if not isinstance(password_data, dict):
|
|
352
|
+
raise ValueError(f"Invalid password entry '{name}' in {self.config_file}: must be a dictionary")
|
|
353
|
+
if 'password' not in password_data and 'encrypted_password' not in password_data:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"Invalid password entry '{name}' in {self.config_file}: 'password' or 'encrypted_password' is required")
|
|
356
|
+
|
|
357
|
+
# Validate settings section if it exists
|
|
358
|
+
if 'settings' in config:
|
|
359
|
+
if not isinstance(config['settings'], dict):
|
|
360
|
+
raise ValueError(f"Invalid config file {self.config_file}: 'settings' must be a dictionary")
|
|
361
|
+
|
|
362
|
+
logger.info(f"Loaded config from {self.config_file}")
|
|
363
|
+
return config
|
|
364
|
+
except Exception as e:
|
|
365
|
+
raise ValueError(f"Failed to load config file {self.config_file}: {e}")
|
|
366
|
+
|
|
367
|
+
def _apply_settings(self) -> None:
|
|
368
|
+
"""Apply global settings from config."""
|
|
369
|
+
global settings
|
|
370
|
+
|
|
371
|
+
config_settings = self.config.get('settings', {})
|
|
372
|
+
settings.update(config_settings)
|
|
373
|
+
|
|
374
|
+
# Apply specific settings that need special handling
|
|
375
|
+
default_tz = settings.get('default_timezone')
|
|
376
|
+
if default_tz:
|
|
377
|
+
try:
|
|
378
|
+
from .etl.transforms.datetime import set_default_timezone
|
|
379
|
+
set_default_timezone(default_tz)
|
|
380
|
+
logger.info(f"Set default timezone to: {default_tz}")
|
|
381
|
+
except ValueError as e:
|
|
382
|
+
logger.warning(f"Failed to set default timezone '{default_tz}': {e}")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def get_setting(self, key: str, default: Any = None) -> Any:
|
|
386
|
+
"""
|
|
387
|
+
Get a setting value from the config.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
key: Setting key (supports dot notation like 'database.timeout')
|
|
391
|
+
default: Default value if key not found
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Setting value or default
|
|
395
|
+
|
|
396
|
+
Example:
|
|
397
|
+
timeout = config.get_setting('database.timeout', 30)
|
|
398
|
+
tz = config.get_setting('default_timezone', 'UTC')
|
|
399
|
+
"""
|
|
400
|
+
config_settings = self.config.get('settings', {})
|
|
401
|
+
|
|
402
|
+
# Support dot notation for nested settings
|
|
403
|
+
keys = key.split('.')
|
|
404
|
+
value = config_settings
|
|
405
|
+
|
|
406
|
+
for k in keys:
|
|
407
|
+
if isinstance(value, dict) and k in value:
|
|
408
|
+
value = value[k]
|
|
409
|
+
elif default:
|
|
410
|
+
return default
|
|
411
|
+
else:
|
|
412
|
+
return settings.get(k)
|
|
413
|
+
|
|
414
|
+
return value
|
|
415
|
+
|
|
416
|
+
def set_setting(self, key: str, value: Any) -> None:
|
|
417
|
+
"""
|
|
418
|
+
Set a setting value and save config.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
key: Setting key (supports dot notation)
|
|
422
|
+
value: Setting value
|
|
423
|
+
"""
|
|
424
|
+
settings = self.config.setdefault('settings', {})
|
|
425
|
+
|
|
426
|
+
# Support dot notation for nested settings
|
|
427
|
+
keys = key.split('.')
|
|
428
|
+
current = settings
|
|
429
|
+
|
|
430
|
+
for k in keys[:-1]:
|
|
431
|
+
current = current.setdefault(k, {})
|
|
432
|
+
|
|
433
|
+
current[keys[-1]] = value
|
|
434
|
+
self._save_config()
|
|
435
|
+
|
|
436
|
+
# Re-apply settings
|
|
437
|
+
self._apply_settings()
|
|
438
|
+
|
|
439
|
+
def _get_encryption_key(self) -> bytes:
|
|
440
|
+
"""Get encryption key from keyring or environment variable."""
|
|
441
|
+
# environment variable takes precedence
|
|
442
|
+
key_str = os.environ.get('DBTK_ENCRYPTION_KEY')
|
|
443
|
+
if key_str:
|
|
444
|
+
logger.debug("Using DBTK_ENCRYPTION_KEY from environment")
|
|
445
|
+
return key_str.encode()
|
|
446
|
+
|
|
447
|
+
if HAS_KEYRING:
|
|
448
|
+
try:
|
|
449
|
+
key_str = keyring.get_password('dbtk', 'encryption_key')
|
|
450
|
+
if key_str:
|
|
451
|
+
logger.debug("Using encryption key from keyring")
|
|
452
|
+
return key_str.encode()
|
|
453
|
+
else:
|
|
454
|
+
raise ValueError("Keyring entry exists but is empty")
|
|
455
|
+
except Exception as e:
|
|
456
|
+
logger.warning(f"Keyring access failed: {e}")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
if HAS_KEYRING:
|
|
460
|
+
msg = dedent("""\
|
|
461
|
+
Encryption key not found in environment or keyring.
|
|
462
|
+
Run: `dbtk store-key` to generate and store a new encryption key in the keyring.
|
|
463
|
+
""")
|
|
464
|
+
else:
|
|
465
|
+
msg = dedent("""\
|
|
466
|
+
Encryption key not found in environment or keyring.
|
|
467
|
+
Run `dbtk generate-key` to generate and a new encryption key
|
|
468
|
+
then in the DBTK_ENCRYPTION_KEY environment variable.""")
|
|
469
|
+
raise ValueError(msg)
|
|
470
|
+
|
|
471
|
+
def _get_fernet(self) -> Fernet:
|
|
472
|
+
"""Get or create Fernet instance for encryption/decryption."""
|
|
473
|
+
if self._fernet is None:
|
|
474
|
+
self._fernet = Fernet(self._get_encryption_key())
|
|
475
|
+
return self._fernet
|
|
476
|
+
|
|
477
|
+
def decrypt_password(self, encrypted_password: str) -> str:
|
|
478
|
+
"""Decrypt an encrypted password."""
|
|
479
|
+
try:
|
|
480
|
+
fernet = self._get_fernet()
|
|
481
|
+
return fernet.decrypt(encrypted_password.encode()).decode()
|
|
482
|
+
except Exception as e:
|
|
483
|
+
raise ValueError(f"Failed to decrypt password: {e}")
|
|
484
|
+
|
|
485
|
+
def encrypt_password(self, password: str) -> str:
|
|
486
|
+
"""Encrypt a password for storage."""
|
|
487
|
+
try:
|
|
488
|
+
fernet = self._get_fernet()
|
|
489
|
+
return fernet.encrypt(password.encode()).decode()
|
|
490
|
+
except Exception as e:
|
|
491
|
+
raise ValueError(f"Failed to encrypt password: {e}")
|
|
492
|
+
|
|
493
|
+
def get_connection_config(self, name: str) -> Dict[str, Any]:
|
|
494
|
+
"""Get configuration for a named connection."""
|
|
495
|
+
connections = self.config.get('connections', {})
|
|
496
|
+
|
|
497
|
+
if name not in connections:
|
|
498
|
+
available = list(connections.keys())
|
|
499
|
+
raise ValueError(
|
|
500
|
+
f"Connection '{name}' not found in config. "
|
|
501
|
+
f"Available connections: {available}"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
config = connections[name].copy()
|
|
505
|
+
|
|
506
|
+
# Handle password decryption first (before env var expansion)
|
|
507
|
+
if 'encrypted_password' in config:
|
|
508
|
+
config['password'] = self.decrypt_password(config['encrypted_password'])
|
|
509
|
+
del config['encrypted_password']
|
|
510
|
+
|
|
511
|
+
# Expand environment variables in all string connection params
|
|
512
|
+
# Supports ${VAR} and ${VAR:default} syntax
|
|
513
|
+
for key, value in config.items():
|
|
514
|
+
if isinstance(value, str):
|
|
515
|
+
config[key] = _expand_env_var(value)
|
|
516
|
+
|
|
517
|
+
return config
|
|
518
|
+
|
|
519
|
+
def list_connections(self) -> list:
|
|
520
|
+
"""List all available connection names."""
|
|
521
|
+
return list(self.config.get('connections', {}).keys())
|
|
522
|
+
|
|
523
|
+
def get_password(self, name: str) -> str:
|
|
524
|
+
"""
|
|
525
|
+
Get a stored password by name.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
name: Password name/key
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
Decrypted password string
|
|
532
|
+
|
|
533
|
+
Raises:
|
|
534
|
+
ValueError: If password not found or decryption fails
|
|
535
|
+
"""
|
|
536
|
+
passwords = self.config.get('passwords', {})
|
|
537
|
+
|
|
538
|
+
if name not in passwords:
|
|
539
|
+
available = list(passwords.keys())
|
|
540
|
+
raise ValueError(
|
|
541
|
+
f"Password '{name}' not found in config. "
|
|
542
|
+
f"Available passwords: {available}"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
password_entry = passwords[name]
|
|
546
|
+
|
|
547
|
+
# Handle encrypted passwords
|
|
548
|
+
if 'encrypted_password' in password_entry:
|
|
549
|
+
return self.decrypt_password(password_entry['encrypted_password'])
|
|
550
|
+
|
|
551
|
+
# Handle plain text passwords (with env var expansion)
|
|
552
|
+
if 'password' in password_entry:
|
|
553
|
+
return _expand_env_var(password_entry['password'])
|
|
554
|
+
|
|
555
|
+
raise ValueError(f"Invalid password entry '{name}': no password or encrypted_password found")
|
|
556
|
+
|
|
557
|
+
def list_passwords(self) -> list:
|
|
558
|
+
"""List all available password names."""
|
|
559
|
+
return list(self.config.get('passwords', {}).keys())
|
|
560
|
+
|
|
561
|
+
def add_password(self, name: str, password: str, description: str = None, encrypt: bool = True) -> None:
|
|
562
|
+
"""
|
|
563
|
+
Add or update a password entry.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
name: Password name/key
|
|
567
|
+
password: Password value
|
|
568
|
+
description: Optional description
|
|
569
|
+
encrypt: Whether to encrypt the password (default: True)
|
|
570
|
+
"""
|
|
571
|
+
passwords = self.config.setdefault('passwords', {})
|
|
572
|
+
|
|
573
|
+
entry = {}
|
|
574
|
+
if description:
|
|
575
|
+
entry['description'] = description
|
|
576
|
+
|
|
577
|
+
if encrypt:
|
|
578
|
+
entry['encrypted_password'] = self.encrypt_password(password)
|
|
579
|
+
else:
|
|
580
|
+
entry['password'] = password
|
|
581
|
+
|
|
582
|
+
passwords[name] = entry
|
|
583
|
+
self._save_config()
|
|
584
|
+
|
|
585
|
+
logger.info(f"Password '{name}' {'updated' if name in passwords else 'added'} successfully")
|
|
586
|
+
|
|
587
|
+
def remove_password(self, name: str) -> None:
|
|
588
|
+
"""
|
|
589
|
+
Remove a password entry.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
name: Password name to remove
|
|
593
|
+
|
|
594
|
+
Raises:
|
|
595
|
+
ValueError: If password not found
|
|
596
|
+
"""
|
|
597
|
+
passwords = self.config.get('passwords', {})
|
|
598
|
+
|
|
599
|
+
if name not in passwords:
|
|
600
|
+
available = list(passwords.keys())
|
|
601
|
+
raise ValueError(
|
|
602
|
+
f"Password '{name}' not found in config. "
|
|
603
|
+
f"Available passwords: {available}"
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
del passwords[name]
|
|
607
|
+
self._save_config()
|
|
608
|
+
|
|
609
|
+
logger.info(f"Password '{name}' removed successfully")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _save_config(self) -> None:
|
|
613
|
+
"""Save config with consistent key ordering."""
|
|
614
|
+
ordered_config = {}
|
|
615
|
+
|
|
616
|
+
# Settings first
|
|
617
|
+
if 'settings' in self.config:
|
|
618
|
+
ordered_config['settings'] = self.config['settings']
|
|
619
|
+
|
|
620
|
+
# Connections second, sorted by name
|
|
621
|
+
if 'connections' in self.config:
|
|
622
|
+
ordered_connections = {}
|
|
623
|
+
connection_key_order = ['type', 'database', 'user', 'password', 'encrypted_password', 'host', 'port']
|
|
624
|
+
|
|
625
|
+
for conn_name in sorted(self.config['connections'].keys()):
|
|
626
|
+
connection = self.config['connections'][conn_name]
|
|
627
|
+
ordered_connection = {}
|
|
628
|
+
|
|
629
|
+
# Add keys in preferred order
|
|
630
|
+
for key in connection_key_order:
|
|
631
|
+
if key in connection:
|
|
632
|
+
ordered_connection[key] = connection[key]
|
|
633
|
+
|
|
634
|
+
# Add remaining keys alphabetically
|
|
635
|
+
remaining_keys = sorted(set(connection.keys()) - set(connection_key_order))
|
|
636
|
+
for key in remaining_keys:
|
|
637
|
+
ordered_connection[key] = connection[key]
|
|
638
|
+
|
|
639
|
+
ordered_connections[conn_name] = ordered_connection
|
|
640
|
+
|
|
641
|
+
ordered_config['connections'] = ordered_connections
|
|
642
|
+
|
|
643
|
+
# Passwords last, sorted by name
|
|
644
|
+
if 'passwords' in self.config:
|
|
645
|
+
ordered_config['passwords'] = dict(sorted(self.config['passwords'].items()))
|
|
646
|
+
|
|
647
|
+
with open(self.config_file, 'w') as f:
|
|
648
|
+
yaml.safe_dump(ordered_config, f, default_flow_style=False, sort_keys=False)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# Global config manager instance
|
|
652
|
+
_config_manager: Optional[ConfigManager] = None
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def store_key(key: Optional[str] = None, force: bool = False) -> None:
|
|
656
|
+
"""CLI utility to store encryption key in system keyring."""
|
|
657
|
+
if not HAS_KEYRING:
|
|
658
|
+
raise ValueError("Keyring not available. Install keyring package to store key in system keyring.")
|
|
659
|
+
|
|
660
|
+
try:
|
|
661
|
+
# check if we have a current key
|
|
662
|
+
current_key = keyring.get_password("dbtk", "encryption_key")
|
|
663
|
+
except Exception:
|
|
664
|
+
current_key = None
|
|
665
|
+
|
|
666
|
+
if current_key:
|
|
667
|
+
if force:
|
|
668
|
+
msg = "Encryption key already stored in system keyring. Overwriting!"
|
|
669
|
+
logger.warning(msg)
|
|
670
|
+
print(msg)
|
|
671
|
+
else:
|
|
672
|
+
msg = "Encryption key already stored in system keyring. Use --force to overwrite."
|
|
673
|
+
logger.warning(msg)
|
|
674
|
+
print(msg)
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
if key is None:
|
|
678
|
+
key = _generate_encryption_key()
|
|
679
|
+
else:
|
|
680
|
+
# make sure passed in key is valid
|
|
681
|
+
if not _valid_fernet(key):
|
|
682
|
+
raise ValueError("Invalid encryption key. Must be 32 url-safe base64-encoded bytes.")
|
|
683
|
+
|
|
684
|
+
try:
|
|
685
|
+
keyring.set_password("dbtk", "encryption_key", key)
|
|
686
|
+
msg = "Stored encryption key in system keyring"
|
|
687
|
+
logger.info(msg)
|
|
688
|
+
print(msg)
|
|
689
|
+
return
|
|
690
|
+
except Exception as e:
|
|
691
|
+
msg = f"Failed to store encryption key in system keyring: {e}"
|
|
692
|
+
logger.error(msg)
|
|
693
|
+
raise ValueError(msg)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _generate_encryption_key() -> str:
|
|
697
|
+
""" Generate encryption key with Fernet """
|
|
698
|
+
return Fernet.generate_key().decode()
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def generate_encryption_key() -> str:
|
|
702
|
+
"""
|
|
703
|
+
Generate a random encryption key.
|
|
704
|
+
|
|
705
|
+
This function generates a random encryption key that can be used to encrypt
|
|
706
|
+
and decrypt data securely. The key is returned as a string and should be
|
|
707
|
+
stored in the DBTK_ENCRYPTION_KEY environment variable
|
|
708
|
+
or on keyring by calling `dbtk store-key [your key]`
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
str: A randomly generated encryption key."""
|
|
713
|
+
key = _generate_encryption_key()
|
|
714
|
+
if HAS_KEYRING:
|
|
715
|
+
msg = "Key generated. Store in system keyring with `dbtk store-key [your key]`"
|
|
716
|
+
else:
|
|
717
|
+
msg = "Key generated. Store in DBTK_ENCRYPTION_KEY environment variable"
|
|
718
|
+
print(msg)
|
|
719
|
+
return key
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def set_config_file(config_file: Union[str, Path]) -> None:
|
|
723
|
+
"""Set the configuration file to use globally."""
|
|
724
|
+
global _config_manager
|
|
725
|
+
_config_manager = ConfigManager(config_file)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def connect(name: str, password: str = None, config_file: Optional[Union[str, Path]] = None) -> Database:
|
|
729
|
+
"""
|
|
730
|
+
Connect to a named database from configuration.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
name: Connection name from config file
|
|
734
|
+
password: Optional password if not stored in config
|
|
735
|
+
config_file: Optional path to config file
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
Database connection instance
|
|
739
|
+
|
|
740
|
+
Example:
|
|
741
|
+
db = connect('prod_warehouse')
|
|
742
|
+
cursor = db.cursor()
|
|
743
|
+
cursor.execute("SELECT * FROM users")
|
|
744
|
+
"""
|
|
745
|
+
global _config_manager
|
|
746
|
+
|
|
747
|
+
# Use provided config file or global instance
|
|
748
|
+
if config_file:
|
|
749
|
+
config_mgr = ConfigManager(config_file)
|
|
750
|
+
else:
|
|
751
|
+
if _config_manager is None:
|
|
752
|
+
_config_manager = ConfigManager()
|
|
753
|
+
config_mgr = _config_manager
|
|
754
|
+
|
|
755
|
+
config = config_mgr.get_connection_config(name)
|
|
756
|
+
if password:
|
|
757
|
+
config['password'] = password
|
|
758
|
+
|
|
759
|
+
# Extract database type
|
|
760
|
+
db_type = config.pop('type', None)
|
|
761
|
+
if not db_type:
|
|
762
|
+
db_type = config.pop('database_type', 'postgres')
|
|
763
|
+
# Extract driver if specified
|
|
764
|
+
driver = config.pop('driver', None)
|
|
765
|
+
cursor_settings = config.pop('cursor', None)
|
|
766
|
+
if cursor_settings is not None:
|
|
767
|
+
unknown = set(cursor_settings.keys()) - set(Cursor.WRAPPER_SETTINGS)
|
|
768
|
+
if unknown:
|
|
769
|
+
logger.warning(f"Unknown cursor settings (ignored): {unknown}")
|
|
770
|
+
|
|
771
|
+
# remove any params that are not allowed for the database type
|
|
772
|
+
allowed_params = _get_params_for_database(db_type)
|
|
773
|
+
config = {key: val for key, val in config.items() if key in allowed_params}
|
|
774
|
+
|
|
775
|
+
# Create database connection with connection name
|
|
776
|
+
return Database.create(db_type, driver=driver, connection_name=name,
|
|
777
|
+
cursor_settings=cursor_settings, **config)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def get_password(name: str, config_file: Optional[Union[str, Path]] = None) -> str:
|
|
781
|
+
"""
|
|
782
|
+
Get a stored password from configuration.
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
name: Password name from config file
|
|
786
|
+
config_file: Optional path to config file
|
|
787
|
+
|
|
788
|
+
Returns:
|
|
789
|
+
Decrypted password string
|
|
790
|
+
|
|
791
|
+
Example:
|
|
792
|
+
api_key = get_password('openai_api_key')
|
|
793
|
+
secret = get_password('jwt_secret')
|
|
794
|
+
"""
|
|
795
|
+
global _config_manager
|
|
796
|
+
|
|
797
|
+
# Use provided config file or global instance
|
|
798
|
+
if config_file:
|
|
799
|
+
config_mgr = ConfigManager(config_file)
|
|
800
|
+
else:
|
|
801
|
+
if _config_manager is None:
|
|
802
|
+
_config_manager = ConfigManager()
|
|
803
|
+
config_mgr = _config_manager
|
|
804
|
+
|
|
805
|
+
return config_mgr.get_password(name)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def get_setting(key: str, default: Any = None, config_file: Optional[Union[str, Path]] = None) -> Any:
|
|
809
|
+
"""
|
|
810
|
+
Get a setting value from configuration.
|
|
811
|
+
|
|
812
|
+
Args:
|
|
813
|
+
key: Setting key (supports dot notation like 'database.timeout')
|
|
814
|
+
default: Default value if key not found
|
|
815
|
+
config_file: Optional path to config file
|
|
816
|
+
|
|
817
|
+
Returns:
|
|
818
|
+
Setting value or default
|
|
819
|
+
|
|
820
|
+
Example:
|
|
821
|
+
timeout = get_setting('database.timeout', 30)
|
|
822
|
+
tz = get_setting('default_timezone', 'UTC')
|
|
823
|
+
"""
|
|
824
|
+
global _config_manager
|
|
825
|
+
|
|
826
|
+
# Use provided config file or global instance
|
|
827
|
+
if config_file:
|
|
828
|
+
config_mgr = ConfigManager(config_file)
|
|
829
|
+
else:
|
|
830
|
+
if _config_manager is None:
|
|
831
|
+
_config_manager = ConfigManager()
|
|
832
|
+
config_mgr = _config_manager
|
|
833
|
+
|
|
834
|
+
return config_mgr.get_setting(key, default)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def encrypt_password(password: str = None, encryption_key: str = None) -> str:
|
|
838
|
+
"""
|
|
839
|
+
CLI utility function to encrypt a password.
|
|
840
|
+
|
|
841
|
+
Args:
|
|
842
|
+
password: Password to encrypt (if None, prompts for input)
|
|
843
|
+
encryption_key: Optional encryption key. If None, uses DBTK_ENCRYPTION_KEY env var
|
|
844
|
+
|
|
845
|
+
Returns:
|
|
846
|
+
str: Encrypted password
|
|
847
|
+
"""
|
|
848
|
+
if password is None:
|
|
849
|
+
import getpass
|
|
850
|
+
password = getpass.getpass("Enter password to encrypt: ")
|
|
851
|
+
|
|
852
|
+
if encryption_key:
|
|
853
|
+
# Use provided key
|
|
854
|
+
fernet = Fernet(encryption_key.encode())
|
|
855
|
+
encrypted = fernet.encrypt(password.encode()).decode()
|
|
856
|
+
else:
|
|
857
|
+
# Use DBTK_ENCRYPTION_KEY to encrypt
|
|
858
|
+
temp_config = ConfigManager.__new__(ConfigManager)
|
|
859
|
+
temp_config._fernet = None
|
|
860
|
+
encrypted = temp_config.encrypt_password(password)
|
|
861
|
+
|
|
862
|
+
print(encrypted)
|
|
863
|
+
return encrypted
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def encrypt_config_file(filename: Union[str, Path]) -> None:
|
|
867
|
+
"""CLI Utility to encrypt all passwords in a config file."""
|
|
868
|
+
temp_config = ConfigManager.__new__(ConfigManager)
|
|
869
|
+
temp_config._fernet = None
|
|
870
|
+
with open(filename) as fp:
|
|
871
|
+
config = yaml.safe_load(fp)
|
|
872
|
+
changes = 0
|
|
873
|
+
if config:
|
|
874
|
+
# Encrypt connection passwords
|
|
875
|
+
for key, val in config.get('connections', {}).items():
|
|
876
|
+
password = val.pop('password', None)
|
|
877
|
+
if password:
|
|
878
|
+
enc_password = temp_config.encrypt_password(password)
|
|
879
|
+
if enc_password:
|
|
880
|
+
val['encrypted_password'] = enc_password
|
|
881
|
+
changes += 1
|
|
882
|
+
else:
|
|
883
|
+
# encryption didn't work, put password back
|
|
884
|
+
val['password'] = password
|
|
885
|
+
|
|
886
|
+
# Encrypt standalone passwords
|
|
887
|
+
for key, val in config.get('passwords', {}).items():
|
|
888
|
+
if 'password' in val and 'encrypted_password' not in val:
|
|
889
|
+
password = val.pop('password')
|
|
890
|
+
if password:
|
|
891
|
+
enc_password = temp_config.encrypt_password(password)
|
|
892
|
+
if enc_password:
|
|
893
|
+
val['encrypted_password'] = enc_password
|
|
894
|
+
changes += 1
|
|
895
|
+
else:
|
|
896
|
+
# encryption didn't work, put password back
|
|
897
|
+
val['password'] = password
|
|
898
|
+
|
|
899
|
+
if changes > 0:
|
|
900
|
+
with open(filename, 'w') as fp:
|
|
901
|
+
yaml.safe_dump(config, fp, default_flow_style=False)
|
|
902
|
+
print(f"Encrypted {changes} passwords in {filename}")
|
|
903
|
+
else:
|
|
904
|
+
print(f"No passwords to encrypt in {filename}")
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def migrate_config(source_file: str, target_file: str, new_encryption_key: str) -> None:
|
|
908
|
+
"""Migrate config file with new encryption key."""
|
|
909
|
+
from copy import deepcopy
|
|
910
|
+
source_config_mgr = ConfigManager(source_file)
|
|
911
|
+
new_config = deepcopy(source_config_mgr.config)
|
|
912
|
+
|
|
913
|
+
# Re-encrypt all passwords
|
|
914
|
+
for conn_name, conn_config in new_config.get('connections', {}).items():
|
|
915
|
+
if 'encrypted_password' in conn_config:
|
|
916
|
+
password = source_config_mgr.decrypt_password(conn_config['encrypted_password'])
|
|
917
|
+
conn_config['encrypted_password'] = encrypt_password(password, new_encryption_key)
|
|
918
|
+
|
|
919
|
+
for pwd_name, pwd_config in new_config.get('passwords', {}).items():
|
|
920
|
+
if 'encrypted_password' in pwd_config:
|
|
921
|
+
password = source_config_mgr.decrypt_password(pwd_config['encrypted_password'])
|
|
922
|
+
pwd_config['encrypted_password'] = encrypt_password(password, new_encryption_key)
|
|
923
|
+
|
|
924
|
+
with open(target_file, 'w') as f:
|
|
925
|
+
yaml.safe_dump(new_config, f, default_flow_style=False)
|
|
926
|
+
|
|
927
|
+
def setup_config() -> None:
|
|
928
|
+
"""
|
|
929
|
+
Interactive setup wizard for DBTK configuration.
|
|
930
|
+
|
|
931
|
+
This command guides you through:
|
|
932
|
+
- Choosing config file location (project vs user)
|
|
933
|
+
- Creating a config file from dbtk_sample.yml
|
|
934
|
+
- Setting up encryption (keyring or environment variable)
|
|
935
|
+
- Adding database connections
|
|
936
|
+
|
|
937
|
+
Example:
|
|
938
|
+
# Interactive setup
|
|
939
|
+
dbtk config-setup
|
|
940
|
+
"""
|
|
941
|
+
import getpass
|
|
942
|
+
import shutil
|
|
943
|
+
import os
|
|
944
|
+
|
|
945
|
+
print("\n" + "="*60)
|
|
946
|
+
print("DBTK Configuration Setup Wizard")
|
|
947
|
+
print("="*60)
|
|
948
|
+
|
|
949
|
+
# Determine config file location
|
|
950
|
+
print(dedent("""\
|
|
951
|
+
\nWhere should config file be created?
|
|
952
|
+
1. ~/.config/dbtk.yml (all your projects) [default]
|
|
953
|
+
2. ./dbtk.yml (this project only)
|
|
954
|
+
"""))
|
|
955
|
+
choice = input("Choice [1]: ").strip()
|
|
956
|
+
location = 'project' if choice == '2' else 'user'
|
|
957
|
+
if location == 'project':
|
|
958
|
+
config_path = Path('dbtk.yml')
|
|
959
|
+
else:
|
|
960
|
+
config_path = Path.home() / '.config' / 'dbtk.yml'
|
|
961
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
962
|
+
|
|
963
|
+
# Check if config already exists
|
|
964
|
+
if config_path.exists():
|
|
965
|
+
print(f"\n⚠ Config file already exists at {config_path}")
|
|
966
|
+
overwrite = input("Overwrite? [y/N]: ").strip().lower()
|
|
967
|
+
if overwrite not in ('y', 'yes'):
|
|
968
|
+
print("Cancelled.")
|
|
969
|
+
return
|
|
970
|
+
|
|
971
|
+
# Copy dbtk_sample.yml to target location
|
|
972
|
+
sample_path = Path(__file__).parent / 'dbtk_sample.yml'
|
|
973
|
+
if not sample_path.exists():
|
|
974
|
+
print(f"⚠ Sample config not found at {sample_path}")
|
|
975
|
+
print("Cannot continue without sample file.")
|
|
976
|
+
return
|
|
977
|
+
|
|
978
|
+
shutil.copy(sample_path, config_path)
|
|
979
|
+
print(f"\n✓ Created config from sample at {config_path}")
|
|
980
|
+
|
|
981
|
+
# Also copy sample to ~/.config for reference
|
|
982
|
+
user_sample_path = Path.home() / '.config' / 'dbtk_sample.yml'
|
|
983
|
+
user_sample_path.parent.mkdir(parents=True, exist_ok=True)
|
|
984
|
+
shutil.copy(sample_path, user_sample_path)
|
|
985
|
+
print(f"✓ Copied sample to {user_sample_path} for reference")
|
|
986
|
+
|
|
987
|
+
# Check encryption setup
|
|
988
|
+
print("\n" + "-"*60)
|
|
989
|
+
print("Encryption Setup")
|
|
990
|
+
print("-"*60)
|
|
991
|
+
|
|
992
|
+
# Check if encryption key already exists
|
|
993
|
+
has_env_key = 'DBTK_ENCRYPTION_KEY' in os.environ
|
|
994
|
+
has_keyring_key = False
|
|
995
|
+
|
|
996
|
+
if HAS_KEYRING:
|
|
997
|
+
try:
|
|
998
|
+
keyring_key = keyring.get_password('dbtk', 'encryption_key')
|
|
999
|
+
has_keyring_key = keyring_key is not None
|
|
1000
|
+
except:
|
|
1001
|
+
# keyring is available create and store
|
|
1002
|
+
has_keyring_key = False
|
|
1003
|
+
|
|
1004
|
+
if has_env_key:
|
|
1005
|
+
print("✓ Encryption key found in DBTK_ENCRYPTION_KEY environment variable")
|
|
1006
|
+
print(" Your existing encryption key will be used.")
|
|
1007
|
+
elif has_keyring_key:
|
|
1008
|
+
print("✓ Encryption key found in system keyring")
|
|
1009
|
+
print(" Your existing encryption key will be used.")
|
|
1010
|
+
else:
|
|
1011
|
+
keyring_msg = '✓ installed' if HAS_KEYRING else '⚠ not installed'
|
|
1012
|
+
# No encryption key exists - offer to set one up
|
|
1013
|
+
print(dedent(f"""\
|
|
1014
|
+
No encryption key detected.
|
|
1015
|
+
To encrypt passwords in your config, you need an encryption key.
|
|
1016
|
+
Options:
|
|
1017
|
+
1. Store key in system keyring (recommended - {keyring_msg})
|
|
1018
|
+
2. Store key in DBTK_ENCRYPTION_KEY environment variable
|
|
1019
|
+
3. Skip encryption setup (passwords stored as plaintext)
|
|
1020
|
+
"""))
|
|
1021
|
+
choice = input("\nChoice [1]: ").strip() or '1'
|
|
1022
|
+
|
|
1023
|
+
if choice == '1':
|
|
1024
|
+
# Keyring option
|
|
1025
|
+
if not HAS_KEYRING:
|
|
1026
|
+
print(dedent("""\
|
|
1027
|
+
⚠ The 'keyring' library is not installed.
|
|
1028
|
+
|
|
1029
|
+
To use keyring for encryption keys:
|
|
1030
|
+
1. Install keyring: pip install keyring
|
|
1031
|
+
2. Re-run: dbtk config-setup
|
|
1032
|
+
|
|
1033
|
+
Exiting setup. Please install keyring and restart.
|
|
1034
|
+
"""))
|
|
1035
|
+
return
|
|
1036
|
+
else:
|
|
1037
|
+
# Generate and store in keyring
|
|
1038
|
+
key = generate_encryption_key()
|
|
1039
|
+
store_key(key)
|
|
1040
|
+
print(f"\n✓ Generated encryption key and stored in system keyring")
|
|
1041
|
+
|
|
1042
|
+
elif choice == '2':
|
|
1043
|
+
# Environment variable option
|
|
1044
|
+
key = generate_encryption_key()
|
|
1045
|
+
print(f"\n✓ Generated encryption key:")
|
|
1046
|
+
print(f"\n {key}")
|
|
1047
|
+
if os.name == 'nt':
|
|
1048
|
+
print(dedent("""\
|
|
1049
|
+
Add the DBTK_ENCRYTPION_KEY as an Environmental Variable
|
|
1050
|
+
(System Properties > Advanced > Environmental Variables)"""))
|
|
1051
|
+
else:
|
|
1052
|
+
print(dedent(f"""\
|
|
1053
|
+
Add this to your shell profile (~/.bashrc, ~/zshrc, etc.):
|
|
1054
|
+
export DBTK_ENCRYPTION_KEY='key'"""))
|
|
1055
|
+
# Store in current session so we can continue
|
|
1056
|
+
os.environ['DBTK_ENCRYPTION_KEY'] = key
|
|
1057
|
+
print("\n✓ Key set for this session (you can add connections below)")
|
|
1058
|
+
|
|
1059
|
+
else:
|
|
1060
|
+
print("\nSkipping encryption setup.")
|
|
1061
|
+
print("Passwords will be stored as plaintext in the config file.")
|
|
1062
|
+
|
|
1063
|
+
# Ask about adding connections
|
|
1064
|
+
print("\n" + "-"*60)
|
|
1065
|
+
print("Database Connections")
|
|
1066
|
+
print("-"*60)
|
|
1067
|
+
|
|
1068
|
+
# Load the config we just created
|
|
1069
|
+
with open(config_path) as f:
|
|
1070
|
+
config_data = yaml.safe_load(f)
|
|
1071
|
+
|
|
1072
|
+
if 'connections' not in config_data:
|
|
1073
|
+
config_data['connections'] = {}
|
|
1074
|
+
|
|
1075
|
+
print(dedent("""\
|
|
1076
|
+
Warning: The config file created has lots of comments that will be lost if you continue.
|
|
1077
|
+
The YAML format was designed to be readable and it is recommended to just edit in the
|
|
1078
|
+
text editor of your choice. If you do continue and overwrite the comments, a fully commented
|
|
1079
|
+
sample is also available at ~/.config/dbtk_sample.yml.
|
|
1080
|
+
"""))
|
|
1081
|
+
add_connection = input("\nAdd a database connection now? [y/N]: ").strip().lower()
|
|
1082
|
+
edits = 0
|
|
1083
|
+
while add_connection in ('y', 'yes'):
|
|
1084
|
+
edits += 1
|
|
1085
|
+
conn_name = input("\nConnection name: ").strip()
|
|
1086
|
+
if not conn_name:
|
|
1087
|
+
print("Connection name cannot be empty. Skipping.")
|
|
1088
|
+
break
|
|
1089
|
+
|
|
1090
|
+
if conn_name in config_data['connections']:
|
|
1091
|
+
print(f"⚠ Connection '{conn_name}' already exists in config")
|
|
1092
|
+
overwrite_conn = input("Overwrite? [y/N]: ").strip().lower()
|
|
1093
|
+
if overwrite_conn not in ('y', 'yes'):
|
|
1094
|
+
add_connection = input("\nAdd another connection? [y/N]: ").strip().lower()
|
|
1095
|
+
continue
|
|
1096
|
+
|
|
1097
|
+
db_types = ['postgres', 'oracle', 'mysql', 'sqlserver', 'sqlite']
|
|
1098
|
+
print(f"Database type: {', '.join(db_types)}")
|
|
1099
|
+
db_type = input("Type [postgres]: ").strip().lower() or 'postgres'
|
|
1100
|
+
|
|
1101
|
+
if db_type not in db_types:
|
|
1102
|
+
print(f"Unknown type '{db_type}', using 'postgres'")
|
|
1103
|
+
db_type = 'postgres'
|
|
1104
|
+
|
|
1105
|
+
conn_config = {'type': db_type}
|
|
1106
|
+
|
|
1107
|
+
if db_type != 'sqlite':
|
|
1108
|
+
conn_config['host'] = input("Host [localhost]: ").strip() or 'localhost'
|
|
1109
|
+
|
|
1110
|
+
default_ports = {
|
|
1111
|
+
'postgres': 5432,
|
|
1112
|
+
'oracle': 1521,
|
|
1113
|
+
'mysql': 3306,
|
|
1114
|
+
'sqlserver': 1433
|
|
1115
|
+
}
|
|
1116
|
+
default_port = default_ports.get(db_type, 5432)
|
|
1117
|
+
port_input = input(f"Port [{default_port}]: ").strip()
|
|
1118
|
+
conn_config['port'] = int(port_input) if port_input else default_port
|
|
1119
|
+
|
|
1120
|
+
conn_config['database'] = input("Database name: ").strip()
|
|
1121
|
+
if not conn_config['database']:
|
|
1122
|
+
print("Database name cannot be empty. Skipping connection.")
|
|
1123
|
+
add_connection = input("\nAdd another connection? [y/N]: ").strip().lower()
|
|
1124
|
+
continue
|
|
1125
|
+
|
|
1126
|
+
conn_config['user'] = input("Username: ").strip()
|
|
1127
|
+
if not conn_config['user']:
|
|
1128
|
+
print("Username cannot be empty. Skipping connection.")
|
|
1129
|
+
add_connection = input("\nAdd another connection? [y/N]: ").strip().lower()
|
|
1130
|
+
continue
|
|
1131
|
+
|
|
1132
|
+
password = getpass.getpass("Password (leave empty to skip): ")
|
|
1133
|
+
if password:
|
|
1134
|
+
# Check if we can encrypt
|
|
1135
|
+
can_encrypt = (has_env_key or has_keyring_key or
|
|
1136
|
+
'DBTK_ENCRYPTION_KEY' in os.environ)
|
|
1137
|
+
|
|
1138
|
+
if can_encrypt:
|
|
1139
|
+
encrypt = input("Encrypt this password? [Y/n]: ").strip().lower()
|
|
1140
|
+
if encrypt in ('', 'y', 'yes'):
|
|
1141
|
+
try:
|
|
1142
|
+
# Use existing or newly created key
|
|
1143
|
+
if 'DBTK_ENCRYPTION_KEY' in os.environ:
|
|
1144
|
+
key = os.environ['DBTK_ENCRYPTION_KEY']
|
|
1145
|
+
elif HAS_KEYRING:
|
|
1146
|
+
key = keyring.get_password('dbtk', 'encryption_key')
|
|
1147
|
+
else:
|
|
1148
|
+
key = os.environ.get('DBTK_ENCRYPTION_KEY')
|
|
1149
|
+
|
|
1150
|
+
encrypted = encrypt_password(password, key)
|
|
1151
|
+
conn_config['encrypted_password'] = encrypted
|
|
1152
|
+
print("✓ Password encrypted")
|
|
1153
|
+
except Exception as e:
|
|
1154
|
+
print(f"⚠ Encryption failed: {e}")
|
|
1155
|
+
print("Storing password in plaintext")
|
|
1156
|
+
conn_config['password'] = password
|
|
1157
|
+
else:
|
|
1158
|
+
conn_config['password'] = password
|
|
1159
|
+
else:
|
|
1160
|
+
print("⚠ Encryption not available. Storing password in plaintext.")
|
|
1161
|
+
conn_config['password'] = password
|
|
1162
|
+
else:
|
|
1163
|
+
# SQLite
|
|
1164
|
+
db_path = input("Database file path [./data.db]: ").strip() or './data.db'
|
|
1165
|
+
conn_config['database'] = db_path
|
|
1166
|
+
|
|
1167
|
+
config_data['connections'][conn_name] = conn_config
|
|
1168
|
+
print(f"✓ Added connection '{conn_name}'")
|
|
1169
|
+
|
|
1170
|
+
add_connection = input("\nAdd another connection? [y/N]: ").strip().lower()
|
|
1171
|
+
|
|
1172
|
+
if edits:
|
|
1173
|
+
# Write updated config file
|
|
1174
|
+
with open(config_path, 'w') as f:
|
|
1175
|
+
yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False)
|
|
1176
|
+
|
|
1177
|
+
# Show summary
|
|
1178
|
+
print("\n" + "="*60)
|
|
1179
|
+
print("Setup Complete!")
|
|
1180
|
+
print("="*60)
|
|
1181
|
+
print(f"\nConfig file: {config_path}")
|
|
1182
|
+
print(f"Sample file: {user_sample_path}")
|
|
1183
|
+
|
|
1184
|
+
if edits and config_data.get('connections'):
|
|
1185
|
+
print(f"\nConnections configured: {', '.join(config_data['connections'].keys())}")
|
|
1186
|
+
print("\nTest a connection:")
|
|
1187
|
+
first_conn = list(config_data['connections'].keys())[0]
|
|
1188
|
+
print(f" python -c \"import dbtk; db = dbtk.connect('{first_conn}'); print('Connected!')\"")
|
|
1189
|
+
else:
|
|
1190
|
+
print(f"Edit {config_path} to add connections manually,")
|
|
1191
|
+
print("or run 'dbtk config-setup' again.")
|
|
1192
|
+
|
|
1193
|
+
print("\nSee the sample file for all available settings and examples.")
|
|
1194
|
+
print()
|