baucli 1.0.1__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.
baucli/config.py ADDED
@@ -0,0 +1,244 @@
1
+ """BAUCLI — Configuration manager.
2
+
3
+ Manages ~/.baucli/config.json with multi-database support.
4
+ Passwords: keyring (macOS/Windows/Linux) → fallback to ~/.baucli/.credentials (mode 600).
5
+
6
+ Env vars:
7
+ BAUCLI_HOME — override the config dir (default ~/.baucli). Lets one machine
8
+ hold multiple isolated BAUCLI profiles (one per tenant), e.g.
9
+ `BAUCLI_HOME=~/.baucli-beg baucli sync` and
10
+ `BAUCLI_HOME=~/.baucli-ucbs baucli sync`.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ # Per-profile root: env override → ~/.baucli fallback. Resolved once at import.
20
+ BAUCLI_DIR = Path(os.environ["BAUCLI_HOME"]).expanduser() if os.environ.get("BAUCLI_HOME") \
21
+ else Path.home() / ".baucli"
22
+ # Backwards-compat alias so any external code/tests referencing BAU_DIR still works.
23
+ BAU_DIR = BAUCLI_DIR
24
+ CONFIG_FILE = BAUCLI_DIR / "config.json"
25
+ CREDS_FILE = BAUCLI_DIR / ".credentials"
26
+
27
+ # Keyring service name is suffixed with the profile dir so credentials saved
28
+ # under different BAUCLI_HOME values don't collide in a shared OS keychain.
29
+ KEYRING_SERVICE = "baucli" if BAUCLI_DIR == Path.home() / ".baucli" \
30
+ else f"baucli:{BAUCLI_DIR.name}"
31
+
32
+ # Detect keyring availability at import time
33
+ _keyring = None
34
+ try:
35
+ import keyring as _keyring
36
+ # Test if backend is functional (headless Linux may have no backend)
37
+ _keyring.get_password(KEYRING_SERVICE, "__test__")
38
+ except Exception:
39
+ _keyring = None
40
+
41
+ DEFAULT_CONFIG = {
42
+ "license_key": None,
43
+ "api_url": "https://begbrokerdev.begcloud.com/ords/bauctx/api/v1",
44
+ "api_key": None,
45
+ "tenant_code": None,
46
+ "default_db": None,
47
+ "databases": {},
48
+ "sync_interval_min": 30,
49
+ "request_timeout": 30,
50
+ "context_output": "CLAUDE.md",
51
+ "session_token": None,
52
+ "session_expires_at": None,
53
+ "user_email": None,
54
+ "language": "en_US",
55
+ }
56
+
57
+
58
+ def ensure_dir():
59
+ """Create ~/.baucli/ if it doesn't exist."""
60
+ BAU_DIR.mkdir(parents=True, exist_ok=True)
61
+ # Restrict permissions on Unix
62
+ if sys.platform != "win32":
63
+ os.chmod(BAU_DIR, 0o700)
64
+
65
+
66
+ def load_config() -> dict:
67
+ """Load config from ~/.baucli/config.json."""
68
+ if not CONFIG_FILE.exists():
69
+ return DEFAULT_CONFIG.copy()
70
+ with open(CONFIG_FILE) as f:
71
+ cfg = json.load(f)
72
+ # Merge with defaults for missing keys
73
+ merged = DEFAULT_CONFIG.copy()
74
+ merged.update(cfg)
75
+ return merged
76
+
77
+
78
+ def save_config(cfg: dict):
79
+ """Save config to ~/.baucli/config.json."""
80
+ ensure_dir()
81
+ with open(CONFIG_FILE, "w") as f:
82
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
83
+ if sys.platform != "win32":
84
+ os.chmod(CONFIG_FILE, 0o600)
85
+
86
+
87
+ def load_credentials() -> dict:
88
+ """Load credentials from ~/.baucli/.credentials."""
89
+ if not CREDS_FILE.exists():
90
+ return {}
91
+ with open(CREDS_FILE) as f:
92
+ return json.load(f)
93
+
94
+
95
+ def save_credentials(creds: dict):
96
+ """Save credentials to ~/.baucli/.credentials (restricted permissions)."""
97
+ ensure_dir()
98
+ with open(CREDS_FILE, "w") as f:
99
+ json.dump(creds, f, indent=2)
100
+ if sys.platform != "win32":
101
+ os.chmod(CREDS_FILE, 0o600)
102
+
103
+
104
+ def get_db_password(db_name: str) -> Optional[str]:
105
+ """Get password for a database. Tries keyring first, then file."""
106
+ # Try keyring
107
+ if _keyring:
108
+ try:
109
+ pwd = _keyring.get_password(KEYRING_SERVICE, db_name)
110
+ if pwd:
111
+ return pwd
112
+ except Exception:
113
+ pass
114
+ # Fallback to file
115
+ creds = load_credentials()
116
+ return creds.get(db_name, {}).get("password")
117
+
118
+
119
+ def set_db_password(db_name: str, password: str):
120
+ """Store password for a database. Uses keyring if available, else file."""
121
+ stored_in_keyring = False
122
+ if _keyring:
123
+ try:
124
+ _keyring.set_password(KEYRING_SERVICE, db_name, password)
125
+ stored_in_keyring = True
126
+ except Exception:
127
+ pass
128
+ if not stored_in_keyring:
129
+ # Fallback to file
130
+ creds = load_credentials()
131
+ if db_name not in creds:
132
+ creds[db_name] = {}
133
+ creds[db_name]["password"] = password
134
+ save_credentials(creds)
135
+ return stored_in_keyring
136
+
137
+
138
+ def delete_db_password(db_name: str):
139
+ """Remove a database password from keyring + credentials file. Idempotent."""
140
+ if _keyring:
141
+ try:
142
+ _keyring.delete_password(KEYRING_SERVICE, db_name)
143
+ except Exception:
144
+ pass
145
+ creds = load_credentials()
146
+ if db_name in creds:
147
+ creds.pop(db_name, None)
148
+ save_credentials(creds)
149
+
150
+
151
+ def get_database(cfg: dict, db_name: Optional[str] = None) -> tuple:
152
+ """Get database config by name or default.
153
+
154
+ Returns (db_name, db_config) or (None, None) if not found.
155
+ """
156
+ dbs = cfg.get("databases", {})
157
+ if not dbs:
158
+ return None, None
159
+
160
+ name = db_name or cfg.get("default_db")
161
+ if name and name in dbs:
162
+ return name, dbs[name]
163
+
164
+ # If no default, return first
165
+ if not name and dbs:
166
+ first = next(iter(dbs))
167
+ return first, dbs[first]
168
+
169
+ return None, None
170
+
171
+
172
+ def list_databases(cfg: dict) -> list:
173
+ """List all configured databases."""
174
+ result = []
175
+ default = cfg.get("default_db")
176
+ for name, db in cfg.get("databases", {}).items():
177
+ result.append({
178
+ "name": name,
179
+ "engine": db.get("engine", "?"),
180
+ "host": db.get("host", "?"),
181
+ "port": db.get("port", "?"),
182
+ "service": db.get("service"),
183
+ "wallet_path": db.get("wallet_path"),
184
+ "schemas": db.get("schemas", []),
185
+ "is_default": name == default,
186
+ })
187
+ return result
188
+
189
+
190
+ def add_database(cfg: dict, name: str, engine: str, host: str, port: int,
191
+ service: str = None, database: str = None,
192
+ user: str = None, schemas: list = None,
193
+ wallet_path: str = None) -> dict:
194
+ """Add a database to config.
195
+
196
+ wallet_path (Oracle only): directory of an unzipped Oracle Wallet used to
197
+ reach an Autonomous Database over mTLS TLS. When set, `service` is the TNS
198
+ alias from the wallet's tnsnames.ora (e.g. mydb_high) and host/port are
199
+ optional (the alias encodes the endpoint). Non-secret — the wallet password
200
+ (if any) lives in the keyring, never in config.json.
201
+ """
202
+ db = {
203
+ "engine": engine.lower(),
204
+ "host": host,
205
+ "port": port,
206
+ "user": user,
207
+ "schemas": schemas or [],
208
+ }
209
+ if engine.lower() == "oracle":
210
+ db["service"] = service or database
211
+ if wallet_path:
212
+ db["wallet_path"] = wallet_path
213
+ else:
214
+ db["database"] = database or service
215
+
216
+ if "databases" not in cfg:
217
+ cfg["databases"] = {}
218
+ cfg["databases"][name] = db
219
+
220
+ # Set as default if first database
221
+ if not cfg.get("default_db"):
222
+ cfg["default_db"] = name
223
+
224
+ return cfg
225
+
226
+
227
+ def remove_database(cfg: dict, name: str) -> dict:
228
+ """Remove a database from config."""
229
+ cfg.get("databases", {}).pop(name, None)
230
+ # Remove credentials too
231
+ creds = load_credentials()
232
+ creds.pop(name, None)
233
+ save_credentials(creds)
234
+ # Reset default if needed
235
+ if cfg.get("default_db") == name:
236
+ dbs = cfg.get("databases", {})
237
+ cfg["default_db"] = next(iter(dbs)) if dbs else None
238
+ return cfg
239
+
240
+
241
+ def is_configured() -> bool:
242
+ """Check if BAUCLI is minimally configured."""
243
+ cfg = load_config()
244
+ return bool(cfg.get("api_key") and cfg.get("license_key"))
baucli/db/__init__.py ADDED
@@ -0,0 +1,117 @@
1
+ """BAUCLI — Database extractors base class."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Optional
5
+
6
+
7
+ class BauConnectionError(Exception):
8
+ """Raised when the BAUCLI cannot reach the source database.
9
+
10
+ Carries a short, actionable message (the friendly text shown to the user)
11
+ plus an optional raw cause for `--verbose` debugging. Callers should catch
12
+ this without printing a traceback — the user just needs to know whether
13
+ to fix the host, the credentials, or the database state.
14
+ """
15
+
16
+ def __init__(self, message: str, hint: str = None, cause: Exception = None):
17
+ super().__init__(message)
18
+ self.message = message
19
+ self.hint = hint
20
+ self.cause = cause
21
+
22
+
23
+ class BaseExtractor(ABC):
24
+ """Abstract base for database extractors."""
25
+
26
+ engine: str = "UNKNOWN"
27
+
28
+ def __init__(self, host: str = None, port: int = None, user: str = None,
29
+ password: str = None, service: str = None, database: str = None,
30
+ wallet_path: str = None, wallet_password: str = None):
31
+ self.host = host
32
+ self.port = port
33
+ self.user = user
34
+ self.password = password
35
+ self.service = service
36
+ self.database = database
37
+ # Oracle Wallet (Autonomous DB over mTLS). wallet_path = unzipped wallet
38
+ # dir; wallet_password decrypts ewallet.pem in thin mode (may be None for
39
+ # auto-login SSO wallets used via thick mode).
40
+ self.wallet_path = wallet_path
41
+ self.wallet_password = wallet_password
42
+ self._conn = None
43
+
44
+ @abstractmethod
45
+ def connect(self):
46
+ """Establish database connection."""
47
+ pass
48
+
49
+ @abstractmethod
50
+ def disconnect(self):
51
+ """Close database connection."""
52
+ pass
53
+
54
+ @abstractmethod
55
+ def test_connection(self) -> dict:
56
+ """Test connection and return version info."""
57
+ pass
58
+
59
+ @abstractmethod
60
+ def get_native_identifier(self) -> Optional[str]:
61
+ """Get native database identifier (DBID, system_identifier, etc.)."""
62
+ pass
63
+
64
+ @abstractmethod
65
+ def extract_objects(self, schema: str, since_date: str = None, on_progress=None,
66
+ name_filter: str = None) -> list:
67
+ """Extract PL/SQL objects (packages, functions, procedures, triggers).
68
+
69
+ Args:
70
+ schema: Schema/owner name
71
+ since_date: ISO date string — if set, only modified since this date
72
+ on_progress: callable(current, total, name) — called after each object
73
+ name_filter: case-insensitive exact OBJECT_NAME — when set, only that
74
+ object is extracted (used by `baucli sync --object NAME`).
75
+
76
+ Returns list of dicts:
77
+ [{"name": "PKG_X", "type": "PACKAGE", "source": "CREATE...", "last_ddl": "2026-..."}]
78
+ """
79
+ pass
80
+
81
+ @abstractmethod
82
+ def extract_tables(self, schema: str, since_date: str = None, on_progress=None,
83
+ name_filter: str = None) -> list:
84
+ """Extract table metadata (columns, types, constraints).
85
+
86
+ Args:
87
+ schema: Schema/owner name
88
+ since_date: ISO date string — if set, only modified since this date
89
+ on_progress: callable(current, total, name) — called after each table
90
+ name_filter: case-insensitive exact TABLE_NAME — when set, only that
91
+ table is extracted (used by `baucli sync --table NAME`).
92
+
93
+ Returns list of dicts:
94
+ [{"name": "EMPLOYEES", "type": "TABLE", "columns": [...], "last_ddl": "2026-..."}]
95
+ """
96
+ pass
97
+
98
+ def count_objects(self, schema: str) -> int:
99
+ """Quick COUNT(*) of PL/SQL objects in schema. Override in subclass."""
100
+ return -1 # -1 = not supported
101
+
102
+ def count_tables(self, schema: str) -> int:
103
+ """Quick COUNT(*) of tables/views in schema. Override in subclass."""
104
+ return -1
105
+
106
+ def extract_all(self, schema: str, since_date: str = None) -> list:
107
+ """Extract both PL/SQL objects and tables."""
108
+ objects = self.extract_objects(schema, since_date=since_date)
109
+ tables = self.extract_tables(schema, since_date=since_date)
110
+ return objects + tables
111
+
112
+ def __enter__(self):
113
+ self.connect()
114
+ return self
115
+
116
+ def __exit__(self, exc_type, exc_val, exc_tb):
117
+ self.disconnect()