nl2sql-engine 0.1.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 (192) hide show
  1. nl2sql/__init__.py +38 -0
  2. nl2sql/adapters/__init__.py +0 -0
  3. nl2sql/adapters/duckdb/__init__.py +0 -0
  4. nl2sql/adapters/duckdb/adapter.py +71 -0
  5. nl2sql/adapters/mssql/__init__.py +0 -0
  6. nl2sql/adapters/mssql/adapter.py +122 -0
  7. nl2sql/adapters/mysql/__init__.py +0 -0
  8. nl2sql/adapters/mysql/adapter.py +123 -0
  9. nl2sql/adapters/postgres/__init__.py +0 -0
  10. nl2sql/adapters/postgres/adapter.py +115 -0
  11. nl2sql/adapters/sqlalchemy_base/__init__.py +17 -0
  12. nl2sql/adapters/sqlalchemy_base/adapter.py +476 -0
  13. nl2sql/adapters/sqlalchemy_base/models.py +36 -0
  14. nl2sql/adapters/sqlite/__init__.py +0 -0
  15. nl2sql/adapters/sqlite/adapter.py +88 -0
  16. nl2sql/aggregation/__init__.py +3 -0
  17. nl2sql/aggregation/aggregator.py +98 -0
  18. nl2sql/aggregation/engines/__init__.py +3 -0
  19. nl2sql/aggregation/engines/polars_duckdb.py +125 -0
  20. nl2sql/api/__init__.py +0 -0
  21. nl2sql/api/auth_api.py +60 -0
  22. nl2sql/api/benchmark_api.py +114 -0
  23. nl2sql/api/datasource_api.py +132 -0
  24. nl2sql/api/indexing_api.py +59 -0
  25. nl2sql/api/llm_api.py +82 -0
  26. nl2sql/api/policy_api.py +135 -0
  27. nl2sql/api/query_api.py +138 -0
  28. nl2sql/api/result_api.py +24 -0
  29. nl2sql/api/settings_api.py +65 -0
  30. nl2sql/auth/__init__.py +8 -0
  31. nl2sql/auth/models.py +36 -0
  32. nl2sql/auth/rbac.py +25 -0
  33. nl2sql/cli/__init__.py +0 -0
  34. nl2sql/cli/checks.py +53 -0
  35. nl2sql/cli/commands/__init__.py +0 -0
  36. nl2sql/cli/commands/benchmark.py +34 -0
  37. nl2sql/cli/commands/doctor.py +49 -0
  38. nl2sql/cli/commands/indexing.py +126 -0
  39. nl2sql/cli/commands/info.py +25 -0
  40. nl2sql/cli/commands/install.py +27 -0
  41. nl2sql/cli/commands/policy.py +57 -0
  42. nl2sql/cli/commands/run.py +166 -0
  43. nl2sql/cli/commands/setup.py +415 -0
  44. nl2sql/cli/commands/visualize.py +34 -0
  45. nl2sql/cli/common/decorators.py +34 -0
  46. nl2sql/cli/config.py +24 -0
  47. nl2sql/cli/console.py +52 -0
  48. nl2sql/cli/demo/__init__.py +1 -0
  49. nl2sql/cli/demo/data.py +87 -0
  50. nl2sql/cli/demo/defaults.py +122 -0
  51. nl2sql/cli/demo/factory.py +289 -0
  52. nl2sql/cli/demo/manager.py +230 -0
  53. nl2sql/cli/demo/schemas.py +336 -0
  54. nl2sql/cli/demo/writers/__init__.py +0 -0
  55. nl2sql/cli/demo/writers/docker.py +182 -0
  56. nl2sql/cli/demo/writers/sqlite.py +88 -0
  57. nl2sql/cli/generators/datasources/__init__.py +3 -0
  58. nl2sql/cli/generators/datasources/generator.py +24 -0
  59. nl2sql/cli/generators/datasources/templates.py +7 -0
  60. nl2sql/cli/generators/env/__init__.py +3 -0
  61. nl2sql/cli/generators/env/generator.py +46 -0
  62. nl2sql/cli/generators/env/templates.py +25 -0
  63. nl2sql/cli/generators/llm/__init__.py +3 -0
  64. nl2sql/cli/generators/llm/generator.py +24 -0
  65. nl2sql/cli/generators/llm/templates.py +4 -0
  66. nl2sql/cli/generators/policies/__init__.py +3 -0
  67. nl2sql/cli/generators/policies/generator.py +20 -0
  68. nl2sql/cli/generators/policies/templates.py +2 -0
  69. nl2sql/cli/main.py +195 -0
  70. nl2sql/cli/reporting.py +878 -0
  71. nl2sql/cli/types.py +13 -0
  72. nl2sql/common/__init__.py +1 -0
  73. nl2sql/common/cancellation.py +25 -0
  74. nl2sql/common/context.py +5 -0
  75. nl2sql/common/errors.py +109 -0
  76. nl2sql/common/event_logger.py +88 -0
  77. nl2sql/common/exceptions.py +3 -0
  78. nl2sql/common/logger.py +119 -0
  79. nl2sql/common/metrics.py +50 -0
  80. nl2sql/common/resilience.py +59 -0
  81. nl2sql/common/settings.py +195 -0
  82. nl2sql/configs/__init__.py +6 -0
  83. nl2sql/configs/datasources.py +10 -0
  84. nl2sql/configs/llm.py +36 -0
  85. nl2sql/configs/manager.py +176 -0
  86. nl2sql/configs/policies.py +14 -0
  87. nl2sql/configs/sample_questions.py +11 -0
  88. nl2sql/configs/secrets.py +11 -0
  89. nl2sql/context.py +106 -0
  90. nl2sql/datasources/__init__.py +21 -0
  91. nl2sql/datasources/discovery.py +28 -0
  92. nl2sql/datasources/models.py +21 -0
  93. nl2sql/datasources/protocols.py +3 -0
  94. nl2sql/datasources/registry.py +172 -0
  95. nl2sql/evaluation/__init__.py +6 -0
  96. nl2sql/evaluation/benchmark_runner.py +320 -0
  97. nl2sql/evaluation/evaluator.py +134 -0
  98. nl2sql/evaluation/types.py +22 -0
  99. nl2sql/execution/__init__.py +4 -0
  100. nl2sql/execution/artifacts/__init__.py +3 -0
  101. nl2sql/execution/artifacts/parquet.py +41 -0
  102. nl2sql/execution/artifacts/store.py +165 -0
  103. nl2sql/execution/contracts.py +57 -0
  104. nl2sql/execution/execution_store.py +25 -0
  105. nl2sql/execution/executor/__init__.py +3 -0
  106. nl2sql/execution/executor/sql_executor.py +116 -0
  107. nl2sql/indexing/__init__.py +7 -0
  108. nl2sql/indexing/chunk_builder.py +227 -0
  109. nl2sql/indexing/embeddings.py +180 -0
  110. nl2sql/indexing/enrichment_service.py +316 -0
  111. nl2sql/indexing/models.py +209 -0
  112. nl2sql/indexing/orchestrator.py +90 -0
  113. nl2sql/indexing/vector_store.py +422 -0
  114. nl2sql/llm/__init__.py +8 -0
  115. nl2sql/llm/models.py +10 -0
  116. nl2sql/llm/registry.py +214 -0
  117. nl2sql/pipeline/__init__.py +1 -0
  118. nl2sql/pipeline/graph.py +73 -0
  119. nl2sql/pipeline/graph_utils.py +141 -0
  120. nl2sql/pipeline/nodes/__init__.py +25 -0
  121. nl2sql/pipeline/nodes/aggregator/__init__.py +4 -0
  122. nl2sql/pipeline/nodes/aggregator/node.py +55 -0
  123. nl2sql/pipeline/nodes/aggregator/prompts.py +20 -0
  124. nl2sql/pipeline/nodes/aggregator/schemas.py +28 -0
  125. nl2sql/pipeline/nodes/answer_synthesizer/__init__.py +4 -0
  126. nl2sql/pipeline/nodes/answer_synthesizer/node.py +98 -0
  127. nl2sql/pipeline/nodes/answer_synthesizer/prompts.py +19 -0
  128. nl2sql/pipeline/nodes/answer_synthesizer/schemas.py +24 -0
  129. nl2sql/pipeline/nodes/ast_planner/__init__.py +4 -0
  130. nl2sql/pipeline/nodes/ast_planner/node.py +104 -0
  131. nl2sql/pipeline/nodes/ast_planner/prompts.py +138 -0
  132. nl2sql/pipeline/nodes/ast_planner/schemas.py +236 -0
  133. nl2sql/pipeline/nodes/datasource_resolver/__init__.py +4 -0
  134. nl2sql/pipeline/nodes/datasource_resolver/node.py +253 -0
  135. nl2sql/pipeline/nodes/datasource_resolver/schemas.py +21 -0
  136. nl2sql/pipeline/nodes/decomposer/__init__.py +3 -0
  137. nl2sql/pipeline/nodes/decomposer/node.py +219 -0
  138. nl2sql/pipeline/nodes/decomposer/prompts.py +96 -0
  139. nl2sql/pipeline/nodes/decomposer/schemas.py +143 -0
  140. nl2sql/pipeline/nodes/executor/__init__.py +3 -0
  141. nl2sql/pipeline/nodes/executor/node.py +107 -0
  142. nl2sql/pipeline/nodes/generator/__init__.py +4 -0
  143. nl2sql/pipeline/nodes/generator/node.py +267 -0
  144. nl2sql/pipeline/nodes/generator/schemas.py +13 -0
  145. nl2sql/pipeline/nodes/global_planner/__init__.py +4 -0
  146. nl2sql/pipeline/nodes/global_planner/node.py +186 -0
  147. nl2sql/pipeline/nodes/global_planner/schemas.py +101 -0
  148. nl2sql/pipeline/nodes/refiner/__init__.py +4 -0
  149. nl2sql/pipeline/nodes/refiner/node.py +132 -0
  150. nl2sql/pipeline/nodes/refiner/prompts.py +28 -0
  151. nl2sql/pipeline/nodes/refiner/schemas.py +13 -0
  152. nl2sql/pipeline/nodes/schema_retriever/__init__.py +3 -0
  153. nl2sql/pipeline/nodes/schema_retriever/node.py +252 -0
  154. nl2sql/pipeline/nodes/schema_retriever/schema.py +27 -0
  155. nl2sql/pipeline/nodes/validator/__init__.py +7 -0
  156. nl2sql/pipeline/nodes/validator/node.py +839 -0
  157. nl2sql/pipeline/nodes/validator/schemas.py +12 -0
  158. nl2sql/pipeline/pipeline_runner.py +72 -0
  159. nl2sql/pipeline/routes.py +72 -0
  160. nl2sql/pipeline/runtime.py +153 -0
  161. nl2sql/pipeline/state.py +92 -0
  162. nl2sql/pipeline/subgraphs/__init__.py +5 -0
  163. nl2sql/pipeline/subgraphs/schemas.py +23 -0
  164. nl2sql/pipeline/subgraphs/sql_agent.py +167 -0
  165. nl2sql/public_api.py +199 -0
  166. nl2sql/schema/__init__.py +37 -0
  167. nl2sql/schema/in_memory_store.py +173 -0
  168. nl2sql/schema/protocol.py +88 -0
  169. nl2sql/schema/sqlite_store.py +233 -0
  170. nl2sql/schema/store.py +29 -0
  171. nl2sql/secrets/__init__.py +14 -0
  172. nl2sql/secrets/factory.py +85 -0
  173. nl2sql/secrets/interfaces.py +16 -0
  174. nl2sql/secrets/manager.py +139 -0
  175. nl2sql/secrets/models.py +56 -0
  176. nl2sql/secrets/providers/aws.py +30 -0
  177. nl2sql/secrets/providers/azure.py +49 -0
  178. nl2sql/secrets/providers/env.py +8 -0
  179. nl2sql/secrets/providers/hashi.py +46 -0
  180. nl2sql/services/__init__.py +0 -0
  181. nl2sql/services/callbacks/__init__.py +0 -0
  182. nl2sql/services/callbacks/monitor.py +84 -0
  183. nl2sql/services/callbacks/node_context.py +7 -0
  184. nl2sql/services/callbacks/node_handlers.py +187 -0
  185. nl2sql/services/callbacks/node_metrics.py +14 -0
  186. nl2sql/services/callbacks/presenter.py +12 -0
  187. nl2sql/services/callbacks/token_handler.py +56 -0
  188. nl2sql_engine-0.1.0.dist-info/METADATA +295 -0
  189. nl2sql_engine-0.1.0.dist-info/RECORD +192 -0
  190. nl2sql_engine-0.1.0.dist-info/WHEEL +5 -0
  191. nl2sql_engine-0.1.0.dist-info/entry_points.txt +9 -0
  192. nl2sql_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ import sqlite3
8
+ from typing import List, Optional, Tuple
9
+
10
+ from nl2sql_adapter_sdk.schema import (
11
+ SchemaContract,
12
+ SchemaMetadata,
13
+ SchemaSnapshot,
14
+ TableContract,
15
+ TableMetadata,
16
+ )
17
+
18
+ from .protocol import generate_schema_fingerprint
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class SqliteSchemaStore:
24
+ """SQLite-backed schema store with versioning and per-table access."""
25
+
26
+ def __init__(self, path: Path, max_versions: int = 3):
27
+ self._path = path
28
+ self._max_versions = max_versions
29
+ self._connection = self._connect()
30
+ self._initialize_schema()
31
+
32
+ def _connect(self) -> sqlite3.Connection:
33
+ if str(self._path) != ":memory:":
34
+ self._path.parent.mkdir(parents=True, exist_ok=True)
35
+ connection = sqlite3.connect(str(self._path), check_same_thread=False)
36
+ connection.execute("PRAGMA journal_mode=WAL;")
37
+ return connection
38
+
39
+ def _initialize_schema(self) -> None:
40
+ cursor = self._connection.cursor()
41
+ cursor.execute(
42
+ """
43
+ CREATE TABLE IF NOT EXISTS schema_snapshots (
44
+ datasource_id TEXT NOT NULL,
45
+ schema_version TEXT NOT NULL,
46
+ fingerprint TEXT NOT NULL,
47
+ contract_json TEXT NOT NULL,
48
+ metadata_json TEXT NOT NULL,
49
+ created_at INTEGER NOT NULL,
50
+ PRIMARY KEY (datasource_id, schema_version)
51
+ );
52
+ """
53
+ )
54
+ cursor.execute(
55
+ """
56
+ CREATE INDEX IF NOT EXISTS idx_schema_snapshots_fingerprint
57
+ ON schema_snapshots (datasource_id, fingerprint);
58
+ """
59
+ )
60
+ cursor.execute(
61
+ """
62
+ CREATE INDEX IF NOT EXISTS idx_schema_snapshots_created_at
63
+ ON schema_snapshots (datasource_id, created_at);
64
+ """
65
+ )
66
+ self._connection.commit()
67
+
68
+ def register_snapshot(self, snapshot: SchemaSnapshot) -> Tuple[str, List[str]]:
69
+ fingerprint = generate_schema_fingerprint(snapshot.contract)
70
+ existing_version = self._get_version_by_fingerprint(
71
+ snapshot.contract.datasource_id, fingerprint
72
+ )
73
+ if existing_version:
74
+ logger.info(
75
+ "Schema for %s already exists with version %s",
76
+ snapshot.contract.datasource_id,
77
+ existing_version,
78
+ )
79
+ return existing_version, []
80
+
81
+ now = datetime.utcnow()
82
+
83
+ ts = now.strftime("%Y%m%d%H%M%S")
84
+ schema_version = f"{ts}_{fingerprint[:8]}"
85
+ created_at = int(now.timestamp())
86
+
87
+ contract_json = json.dumps(snapshot.contract.model_dump(mode="json"))
88
+ metadata_json = json.dumps(snapshot.metadata.model_dump(mode="json"))
89
+
90
+ with self._connection:
91
+ self._connection.execute(
92
+ """
93
+ INSERT INTO schema_snapshots (
94
+ datasource_id,
95
+ schema_version,
96
+ fingerprint,
97
+ contract_json,
98
+ metadata_json,
99
+ created_at
100
+ ) VALUES (?, ?, ?, ?, ?, ?);
101
+ """,
102
+ (
103
+ snapshot.contract.datasource_id,
104
+ schema_version,
105
+ fingerprint,
106
+ contract_json,
107
+ metadata_json,
108
+ created_at,
109
+ ),
110
+ )
111
+
112
+ evicted_versions = self._evict_old_versions(snapshot.contract.datasource_id)
113
+ return schema_version, evicted_versions
114
+
115
+ def get_snapshot(
116
+ self, datasource_id: str, schema_version: str
117
+ ) -> Optional[SchemaSnapshot]:
118
+ row = self._connection.execute(
119
+ """
120
+ SELECT contract_json, metadata_json
121
+ FROM schema_snapshots
122
+ WHERE datasource_id = ? AND schema_version = ?;
123
+ """,
124
+ (datasource_id, schema_version),
125
+ ).fetchone()
126
+ if not row:
127
+ return None
128
+ contract = SchemaContract.model_validate(json.loads(row[0]))
129
+ metadata = SchemaMetadata.model_validate(json.loads(row[1]))
130
+ return SchemaSnapshot(contract=contract, metadata=metadata)
131
+
132
+ def get_latest_snapshot(self, datasource_id: str) -> Optional[SchemaSnapshot]:
133
+ latest_version = self.get_latest_version(datasource_id)
134
+ if not latest_version:
135
+ return None
136
+ return self.get_snapshot(datasource_id, latest_version)
137
+
138
+ def get_latest_version(self, datasource_id: str) -> Optional[str]:
139
+ with self._connection:
140
+ row = self._connection.execute(
141
+ """
142
+ SELECT schema_version
143
+ FROM schema_snapshots
144
+ WHERE datasource_id = ?
145
+ ORDER BY created_at DESC
146
+ LIMIT 1;
147
+ """,
148
+ (datasource_id,),
149
+ ).fetchone()
150
+
151
+ return row[0] if row else None
152
+
153
+ def list_versions(self, datasource_id: str) -> List[str]:
154
+ rows = self._connection.execute(
155
+ """
156
+ SELECT schema_version
157
+ FROM schema_snapshots
158
+ WHERE datasource_id = ?
159
+ ORDER BY created_at ASC;
160
+ """,
161
+ (datasource_id,),
162
+ ).fetchall()
163
+ return [row[0] for row in rows]
164
+
165
+ def get_table_contract(
166
+ self,
167
+ datasource_id: str,
168
+ schema_version: str,
169
+ table_key: str,
170
+ ) -> Optional[TableContract]:
171
+ snapshot = self.get_snapshot(datasource_id, schema_version)
172
+ if not snapshot:
173
+ return None
174
+ return snapshot.contract.tables.get(table_key)
175
+
176
+ def get_table_metadata(
177
+ self,
178
+ datasource_id: str,
179
+ schema_version: str,
180
+ table_key: str,
181
+ ) -> Optional[TableMetadata]:
182
+ snapshot = self.get_snapshot(datasource_id, schema_version)
183
+ if not snapshot:
184
+ return None
185
+ return snapshot.metadata.tables.get(table_key)
186
+
187
+ def _get_version_by_fingerprint(
188
+ self, datasource_id: str, fingerprint: str
189
+ ) -> Optional[str]:
190
+ row = self._connection.execute(
191
+ """
192
+ SELECT schema_version
193
+ FROM schema_snapshots
194
+ WHERE datasource_id = ? AND fingerprint = ?
195
+ ORDER BY created_at DESC
196
+ LIMIT 1;
197
+ """,
198
+ (datasource_id, fingerprint),
199
+ ).fetchone()
200
+ return row[0] if row else None
201
+
202
+ def _evict_old_versions(self, datasource_id: str) -> List[str]:
203
+ rows = self._connection.execute(
204
+ """
205
+ SELECT schema_version
206
+ FROM schema_snapshots
207
+ WHERE datasource_id = ?
208
+ ORDER BY created_at ASC;
209
+ """,
210
+ (datasource_id,),
211
+ ).fetchall()
212
+ versions = [row[0] for row in rows]
213
+ if len(versions) <= self._max_versions:
214
+ return []
215
+
216
+ evicted_versions = versions[: len(versions) - self._max_versions]
217
+ with self._connection:
218
+ self._connection.executemany(
219
+ """
220
+ DELETE FROM schema_snapshots
221
+ WHERE datasource_id = ? AND schema_version = ?;
222
+ """,
223
+ [(datasource_id, version) for version in evicted_versions],
224
+ )
225
+
226
+ for version in evicted_versions:
227
+ logger.info(
228
+ "Evicted old schema version for %s: %s",
229
+ datasource_id,
230
+ version,
231
+ )
232
+
233
+ return evicted_versions
nl2sql/schema/store.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from .in_memory_store import InMemorySchemaStore
7
+ from .protocol import SchemaStore
8
+ from .sqlite_store import SqliteSchemaStore
9
+
10
+
11
+ SCHEMA_STORE_BACKENDS = {
12
+ "memory": InMemorySchemaStore,
13
+ "sqlite": SqliteSchemaStore,
14
+ }
15
+
16
+
17
+ def build_schema_store(
18
+ backend: str,
19
+ max_versions: int,
20
+ path: Optional[Path] = None,
21
+ ) -> SchemaStore:
22
+ backend_key = (backend or "sqlite").lower()
23
+ if backend_key == "memory":
24
+ return InMemorySchemaStore(max_versions=max_versions)
25
+ if backend_key == "sqlite":
26
+ if path is None:
27
+ raise ValueError("schema_store_path is required for sqlite backend.")
28
+ return SqliteSchemaStore(path=path, max_versions=max_versions)
29
+ raise ValueError(f"Unsupported schema store backend: {backend}")
@@ -0,0 +1,14 @@
1
+ from .manager import SecretManager
2
+ from .interfaces import SecretProvider
3
+ from .models import SecretProviderConfig
4
+
5
+ from pathlib import Path
6
+ import os
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ secret_manager = SecretManager()
13
+
14
+ __all__ = ["SecretManager", "SecretProvider", "SecretProviderConfig", "secret_manager"]
@@ -0,0 +1,85 @@
1
+ from typing import Optional, Any, Dict, Type
2
+ import logging
3
+ from .interfaces import SecretProvider
4
+ from nl2sql.configs.secrets import SecretProviderConfig, AwsSecretConfig, AzureSecretConfig, HashiCorpSecretConfig
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class SecretProviderFactory:
9
+ """Factory for creating SecretProvider instances.
10
+
11
+ Centralizes the logic for:
12
+ 1. Dynamic imports (lazy loading of heavy dependencies).
13
+ 2. Dependency checking (Boto3, Azure SDK, etc.).
14
+ 3. Instantiation with or without explicit config.
15
+ """
16
+
17
+ @staticmethod
18
+ def create(config: SecretProviderConfig) -> Optional[SecretProvider]:
19
+ """Creates a provider instance from configuration.
20
+
21
+ Args:
22
+ config: A fully resolved configuration object (no placeholders).
23
+
24
+ Returns:
25
+ The instantiated SecretProvider, or None if dependencies are missing.
26
+ """
27
+ try:
28
+ if isinstance(config, AwsSecretConfig):
29
+ return SecretProviderFactory._create_aws(config)
30
+ elif isinstance(config, AzureSecretConfig):
31
+ return SecretProviderFactory._create_azure(config)
32
+ elif isinstance(config, HashiCorpSecretConfig):
33
+ return SecretProviderFactory._create_hashi(config)
34
+ elif config.type == "env":
35
+ # Env provider is usually bootstrapped, but we support creating it explicitly too
36
+ from .providers.env import EnvironmentSecretProvider
37
+ return EnvironmentSecretProvider()
38
+ else:
39
+ logger.warning(f"Unknown provider type: {config.type}")
40
+ return None
41
+ except ImportError as e:
42
+ logger.warning(f"Skipping provider '{config.id}' ({config.type}): {e}")
43
+ return None
44
+ except Exception as e:
45
+ logger.error(f"Failed to create provider '{config.id}': {e}")
46
+ return None
47
+
48
+ @staticmethod
49
+ def _create_aws(config: AwsSecretConfig) -> SecretProvider:
50
+ try:
51
+ from .providers.aws import AwsSecretProvider
52
+ except ImportError:
53
+ raise ImportError("Missing dependency 'boto3'. Install 'nl2sql-engine[aws]'.")
54
+
55
+ return AwsSecretProvider(
56
+ region_name=config.region_name,
57
+ profile_name=config.profile_name
58
+ )
59
+
60
+ @staticmethod
61
+ def _create_azure(config: AzureSecretConfig) -> SecretProvider:
62
+ try:
63
+ from .providers.azure import AzureSecretProvider
64
+ except ImportError:
65
+ raise ImportError("Missing dependencies. Install 'nl2sql-engine[azure]'.")
66
+
67
+ return AzureSecretProvider(
68
+ vault_url=config.vault_url,
69
+ client_id=config.client_id,
70
+ client_secret=config.client_secret,
71
+ tenant_id=config.tenant_id
72
+ )
73
+
74
+ @staticmethod
75
+ def _create_hashi(config: HashiCorpSecretConfig) -> SecretProvider:
76
+ try:
77
+ from .providers.hashi import HashiCorpSecretProvider
78
+ except ImportError:
79
+ raise ImportError("Missing dependency 'hvac'. Install 'nl2sql-engine[hashicorp]'.")
80
+
81
+ return HashiCorpSecretProvider(
82
+ url=config.url,
83
+ token=config.token,
84
+ mount_point=config.mount_point
85
+ )
@@ -0,0 +1,16 @@
1
+ from typing import Optional, Protocol
2
+
3
+ class SecretProvider(Protocol):
4
+ """Protocol for fetching secrets from secure storage."""
5
+
6
+ def get_secret(self, key: str) -> Optional[str]:
7
+ """
8
+ Retrieve a secret by its key.
9
+
10
+ Args:
11
+ key (str): The identifier for the secret (e.g., 'DB_PASSWORD').
12
+
13
+ Returns:
14
+ Optional[str]: The secret value, or None if not found.
15
+ """
16
+ ...
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import Optional, Dict, Type, List, Any
6
+ from .interfaces import SecretProvider
7
+ from .providers.env import EnvironmentSecretProvider
8
+ from .models import SecretProviderConfig
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class SecretManager:
14
+ """Manages secret resolution using registered providers."""
15
+
16
+ def __init__(self):
17
+ self._providers: Dict[str, SecretProvider] = {
18
+ "env": EnvironmentSecretProvider()
19
+ }
20
+ self._default_provider = "env"
21
+
22
+ def register_provider(self, provider_id: str, provider: SecretProvider) -> None:
23
+ self._providers[provider_id] = provider
24
+
25
+ def configure(self, configs: List[SecretProviderConfig]) -> None:
26
+ """Configures providers from a list of configuration objects.
27
+
28
+ This method implements a Two-Phase Loading strategy:
29
+ 1. Bootstrap: It assumes the 'env' provider is already active.
30
+ 2. Resolution: It resolves configuration values (like client_secret) that may contain
31
+ secret references (e.g., "${env:VAR}") using the existing providers.
32
+ 3. Registration: It instantiates and registers the new providers.
33
+
34
+ Args:
35
+ configs: A list of SecretProviderConfig objects.
36
+ """
37
+ from .factory import SecretProviderFactory
38
+
39
+ for config in configs:
40
+ try:
41
+ if config.type == "env":
42
+ continue
43
+
44
+ updates = {}
45
+ for key, value in config.model_dump(exclude={"id", "type"}).items():
46
+ if isinstance(value, str) and value.startswith("${") and value.endswith("}"):
47
+ updates[key] = self.resolve(value)
48
+
49
+ resolved_config = config.model_copy(update=updates)
50
+ provider = SecretProviderFactory.create(resolved_config)
51
+
52
+ if provider:
53
+ self.register_provider(config.id, provider)
54
+ logger.info(f"Registered secret provider '{config.id}' (type: {config.type})")
55
+
56
+ except Exception as e:
57
+ logger.error(f"Failed to configure secret provider '{config.id}': {e}")
58
+
59
+ def resolve(self, secret_ref: str) -> str:
60
+ """Resolves a secret reference string.
61
+
62
+ Format: ${provider_id:key}
63
+
64
+ Args:
65
+ secret_ref: The reference string to resolve.
66
+
67
+ Returns:
68
+ str: The resolved secret value.
69
+
70
+ Raises:
71
+ ValueError: If the format is invalid, provider is unknown, or secret
72
+ is not found.
73
+ """
74
+ cleaned_ref = secret_ref.replace('${', '').replace('}', '')
75
+
76
+ parts = cleaned_ref.split(':', 1)
77
+ if len(parts) != 2:
78
+ raise ValueError(f"Invalid secret format '{secret_ref}'. Expected '${{provider_id:key}}'.")
79
+
80
+ provider_id, key = parts
81
+ provider = self._providers.get(provider_id)
82
+ if not provider:
83
+ raise ValueError(f"Unknown secret provider ID: '{provider_id}'")
84
+
85
+ val = provider.get_secret(key)
86
+ if val is not None:
87
+ return val
88
+
89
+ raise ValueError(f"Secret not found: {secret_ref}")
90
+
91
+ def resolve_object(self, obj: Any) -> Any:
92
+ """Recursively resolves secret references in a generic object.
93
+
94
+ Traverses the object structure (Pydantic models, dicts, lists) and resolves
95
+ any string values matching the secret pattern "${...}". Handles SecretStr
96
+ by unwrapping, resolving, and re-wrapping.
97
+
98
+ Args:
99
+ obj: The object to resolve secrets in. Can be a Pydantic model,
100
+ dictionary, list, string, or SecretStr.
101
+
102
+ Returns:
103
+ Any: A new object with all secret references resolved to their
104
+ actual values.
105
+ """
106
+ from pydantic import BaseModel, SecretStr
107
+
108
+ if isinstance(obj, str):
109
+ if obj.startswith("${") and obj.endswith("}"):
110
+ return self.resolve(obj)
111
+ return obj
112
+
113
+ if isinstance(obj, SecretStr):
114
+ secret_val = obj.get_secret_value()
115
+ if secret_val and secret_val.startswith("${") and secret_val.endswith("}"):
116
+ resolved_val = self.resolve(secret_val)
117
+ return SecretStr(resolved_val)
118
+ return obj
119
+
120
+ if isinstance(obj, BaseModel):
121
+ updates = {}
122
+ for field_name in type(obj).model_fields.keys():
123
+ val = getattr(obj, field_name)
124
+ resolved = self.resolve_object(val)
125
+ if resolved != val:
126
+ updates[field_name] = resolved
127
+
128
+ if updates:
129
+ return obj.model_copy(update=updates)
130
+ return obj
131
+
132
+ if isinstance(obj, list):
133
+ return [self.resolve_object(item) for item in obj]
134
+
135
+ if isinstance(obj, dict):
136
+ return {k: self.resolve_object(v) for k, v in obj.items()}
137
+
138
+ return obj
139
+
@@ -0,0 +1,56 @@
1
+ from pydantic import BaseModel, Field, SecretStr
2
+ from typing import Literal, Optional, Union
3
+
4
+
5
+ class BaseSecretConfig(BaseModel):
6
+ """Base configuration for all secret providers."""
7
+ id: str = Field(..., description="Unique identifier for this provider instance. Used as the scheme in secret references (e.g. ${id:key}).")
8
+ type: str
9
+
10
+ class AwsSecretConfig(BaseSecretConfig):
11
+ """Configuration for AWS Secrets Manager.
12
+
13
+ Attributes:
14
+ type: Must be 'aws'.
15
+ region_name: AWS Region (e.g. us-east-1). Defaults to env var if None.
16
+ profile_name: AWS Profile. Defaults to standard boto3 lookup if None.
17
+ """
18
+ type: Literal["aws"] = "aws"
19
+ region_name: Optional[str] = Field(None, description="AWS Region. If None, uses AWS_DEFAULT_REGION env var.")
20
+ profile_name: Optional[str] = Field(None, description="AWS Profile. If None, uses default profile.")
21
+
22
+ class AzureSecretConfig(BaseSecretConfig):
23
+ """Configuration for Azure Key Vault.
24
+
25
+ Attributes:
26
+ type: Must be 'azure'.
27
+ vault_url: The URL of the Key Vault.
28
+ client_id: Service Principal ID.
29
+ client_secret: Service Principal Secret.
30
+ tenant_id: Azure Tenant ID.
31
+ """
32
+ type: Literal["azure"] = "azure"
33
+ vault_url: str = Field(..., description="URL of the Key Vault.")
34
+
35
+ client_id: Optional[str] = Field(None, description="Azure Client ID (Service Principal).")
36
+ client_secret: Optional[SecretStr] = Field(None, description="Azure Client Secret.")
37
+ tenant_id: Optional[str] = Field(None, description="Azure Tenant ID.")
38
+
39
+ class HashiCorpSecretConfig(BaseSecretConfig):
40
+ """Configuration for HashiCorp Vault."""
41
+ type: Literal["hashi"] = "hashi"
42
+ url: str = Field(..., description="URL of the HashiCorp Vault server.")
43
+ token: Optional[SecretStr] = Field(None, description="Vault Token.")
44
+ mount_point: str = Field("secret", description="Secrets engine mount point.")
45
+
46
+ class EnvSecretConfig(BaseSecretConfig):
47
+ """Configuration for Environment Variable Provider (Explicit)."""
48
+ type: Literal["env"] = "env"
49
+
50
+ # Polymorphic Union
51
+ SecretProviderConfig = Union[
52
+ AwsSecretConfig,
53
+ AzureSecretConfig,
54
+ HashiCorpSecretConfig,
55
+ EnvSecretConfig
56
+ ]
@@ -0,0 +1,30 @@
1
+ from typing import Optional
2
+ import logging
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+ class AwsSecretProvider:
7
+ """Fetches secrets from AWS Secrets Manager."""
8
+
9
+ def __init__(self, region_name: Optional[str] = None, profile_name: Optional[str] = None):
10
+ try:
11
+ import boto3
12
+ session = boto3.Session(profile_name=profile_name, region_name=region_name)
13
+ self.client = session.client('secretsmanager')
14
+ except ImportError:
15
+ self.client = None
16
+ logger.warning("AWS Secret Provider initialized but 'boto3' is missing. Please install 'nl2sql-engine[aws]' to use AWS secrets.")
17
+
18
+ def get_secret(self, key: str) -> Optional[str]:
19
+ if not self.client:
20
+ raise ImportError("Cannot fetch AWS secret: 'boto3' is not installed.")
21
+
22
+ try:
23
+ # key is the SecretId
24
+ response = self.client.get_secret_value(SecretId=key)
25
+ if 'SecretString' in response:
26
+ return response['SecretString']
27
+ return None # Binary secrets not currently supported for connection strings
28
+ except Exception as e:
29
+ logger.error(f"Failed to fetch secret '{key}' from AWS: {e}")
30
+ return None
@@ -0,0 +1,49 @@
1
+ from typing import Optional
2
+ import os
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ class AzureSecretProvider:
8
+ """Fetches secrets from Azure Key Vault."""
9
+
10
+ def __init__(self, vault_url: Optional[str] = None, client_id: Optional[str] = None, client_secret: Optional[str] = None, tenant_id: Optional[str] = None):
11
+ try:
12
+ from azure.identity import DefaultAzureCredential, ClientSecretCredential
13
+ from azure.keyvault.secrets import SecretClient
14
+
15
+ self.vault_url = vault_url or os.environ.get("AZURE_KEYVAULT_URL")
16
+ if not self.vault_url:
17
+ raise ValueError("Vault URL is required. specific via config or 'AZURE_KEYVAULT_URL'.")
18
+
19
+ if client_id and client_secret and tenant_id:
20
+ credential = ClientSecretCredential(
21
+ tenant_id=tenant_id,
22
+ client_id=client_id,
23
+ client_secret=client_secret
24
+ )
25
+ else:
26
+ credential = DefaultAzureCredential()
27
+
28
+ self.client = SecretClient(vault_url=self.vault_url, credential=credential)
29
+ self._available = True
30
+ except ImportError:
31
+ self.client = None
32
+ self._available = False
33
+ logger.warning("Azure Secret Provider initialized but dependencies missing. Install 'nl2sql-engine[azure]'.")
34
+ except Exception as e:
35
+ self.client = None
36
+ self._available = False
37
+ logger.warning(f"Azure Secret Provider initialization failed: {e}")
38
+
39
+ def get_secret(self, key: str) -> Optional[str]:
40
+ if not self._available or not self.client:
41
+ raise ImportError("Azure Secret Provider is not available.")
42
+
43
+ try:
44
+ # key is the Secret Name
45
+ secret = self.client.get_secret(key)
46
+ return secret.value
47
+ except Exception as e:
48
+ logger.error(f"Failed to fetch secret '{key}' from Azure Key Vault: {e}")
49
+ return None
@@ -0,0 +1,8 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ class EnvironmentSecretProvider:
5
+ """Fetches secrets from environment variables."""
6
+
7
+ def get_secret(self, key: str) -> Optional[str]:
8
+ return os.environ.get(key)