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.
Files changed (50) hide show
  1. dbtk/__init__.py +54 -0
  2. dbtk/cli.py +169 -0
  3. dbtk/config.py +1194 -0
  4. dbtk/cursors.py +566 -0
  5. dbtk/database.py +959 -0
  6. dbtk/dbtk_sample.yml +90 -0
  7. dbtk/defaults.py +29 -0
  8. dbtk/etl/__init__.py +61 -0
  9. dbtk/etl/base_surge.py +257 -0
  10. dbtk/etl/bulk_surge.py +783 -0
  11. dbtk/etl/config_generators.py +458 -0
  12. dbtk/etl/data_surge.py +294 -0
  13. dbtk/etl/managers.py +734 -0
  14. dbtk/etl/table.py +1215 -0
  15. dbtk/etl/transforms/__init__.py +107 -0
  16. dbtk/etl/transforms/address.py +560 -0
  17. dbtk/etl/transforms/core.py +594 -0
  18. dbtk/etl/transforms/database.py +506 -0
  19. dbtk/etl/transforms/datetime.py +497 -0
  20. dbtk/etl/transforms/email.py +72 -0
  21. dbtk/etl/transforms/phone.py +670 -0
  22. dbtk/formats/__init__.py +18 -0
  23. dbtk/formats/edi.py +182 -0
  24. dbtk/logging_utils.py +310 -0
  25. dbtk/readers/__init__.py +26 -0
  26. dbtk/readers/base.py +644 -0
  27. dbtk/readers/csv.py +195 -0
  28. dbtk/readers/data_frame.py +119 -0
  29. dbtk/readers/excel.py +260 -0
  30. dbtk/readers/fixed_width.py +359 -0
  31. dbtk/readers/json.py +323 -0
  32. dbtk/readers/utils.py +394 -0
  33. dbtk/readers/xml.py +260 -0
  34. dbtk/record.py +710 -0
  35. dbtk/utils.py +537 -0
  36. dbtk/writers/__init__.py +46 -0
  37. dbtk/writers/base.py +718 -0
  38. dbtk/writers/csv.py +107 -0
  39. dbtk/writers/database.py +158 -0
  40. dbtk/writers/excel.py +1086 -0
  41. dbtk/writers/fixed_width.py +290 -0
  42. dbtk/writers/json.py +156 -0
  43. dbtk/writers/utils.py +41 -0
  44. dbtk/writers/xml.py +387 -0
  45. dbtk-0.8.0.dist-info/METADATA +305 -0
  46. dbtk-0.8.0.dist-info/RECORD +50 -0
  47. dbtk-0.8.0.dist-info/WHEEL +5 -0
  48. dbtk-0.8.0.dist-info/entry_points.txt +2 -0
  49. dbtk-0.8.0.dist-info/licenses/LICENSE.txt +7 -0
  50. 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()