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.
- nl2sql/__init__.py +38 -0
- nl2sql/adapters/__init__.py +0 -0
- nl2sql/adapters/duckdb/__init__.py +0 -0
- nl2sql/adapters/duckdb/adapter.py +71 -0
- nl2sql/adapters/mssql/__init__.py +0 -0
- nl2sql/adapters/mssql/adapter.py +122 -0
- nl2sql/adapters/mysql/__init__.py +0 -0
- nl2sql/adapters/mysql/adapter.py +123 -0
- nl2sql/adapters/postgres/__init__.py +0 -0
- nl2sql/adapters/postgres/adapter.py +115 -0
- nl2sql/adapters/sqlalchemy_base/__init__.py +17 -0
- nl2sql/adapters/sqlalchemy_base/adapter.py +476 -0
- nl2sql/adapters/sqlalchemy_base/models.py +36 -0
- nl2sql/adapters/sqlite/__init__.py +0 -0
- nl2sql/adapters/sqlite/adapter.py +88 -0
- nl2sql/aggregation/__init__.py +3 -0
- nl2sql/aggregation/aggregator.py +98 -0
- nl2sql/aggregation/engines/__init__.py +3 -0
- nl2sql/aggregation/engines/polars_duckdb.py +125 -0
- nl2sql/api/__init__.py +0 -0
- nl2sql/api/auth_api.py +60 -0
- nl2sql/api/benchmark_api.py +114 -0
- nl2sql/api/datasource_api.py +132 -0
- nl2sql/api/indexing_api.py +59 -0
- nl2sql/api/llm_api.py +82 -0
- nl2sql/api/policy_api.py +135 -0
- nl2sql/api/query_api.py +138 -0
- nl2sql/api/result_api.py +24 -0
- nl2sql/api/settings_api.py +65 -0
- nl2sql/auth/__init__.py +8 -0
- nl2sql/auth/models.py +36 -0
- nl2sql/auth/rbac.py +25 -0
- nl2sql/cli/__init__.py +0 -0
- nl2sql/cli/checks.py +53 -0
- nl2sql/cli/commands/__init__.py +0 -0
- nl2sql/cli/commands/benchmark.py +34 -0
- nl2sql/cli/commands/doctor.py +49 -0
- nl2sql/cli/commands/indexing.py +126 -0
- nl2sql/cli/commands/info.py +25 -0
- nl2sql/cli/commands/install.py +27 -0
- nl2sql/cli/commands/policy.py +57 -0
- nl2sql/cli/commands/run.py +166 -0
- nl2sql/cli/commands/setup.py +415 -0
- nl2sql/cli/commands/visualize.py +34 -0
- nl2sql/cli/common/decorators.py +34 -0
- nl2sql/cli/config.py +24 -0
- nl2sql/cli/console.py +52 -0
- nl2sql/cli/demo/__init__.py +1 -0
- nl2sql/cli/demo/data.py +87 -0
- nl2sql/cli/demo/defaults.py +122 -0
- nl2sql/cli/demo/factory.py +289 -0
- nl2sql/cli/demo/manager.py +230 -0
- nl2sql/cli/demo/schemas.py +336 -0
- nl2sql/cli/demo/writers/__init__.py +0 -0
- nl2sql/cli/demo/writers/docker.py +182 -0
- nl2sql/cli/demo/writers/sqlite.py +88 -0
- nl2sql/cli/generators/datasources/__init__.py +3 -0
- nl2sql/cli/generators/datasources/generator.py +24 -0
- nl2sql/cli/generators/datasources/templates.py +7 -0
- nl2sql/cli/generators/env/__init__.py +3 -0
- nl2sql/cli/generators/env/generator.py +46 -0
- nl2sql/cli/generators/env/templates.py +25 -0
- nl2sql/cli/generators/llm/__init__.py +3 -0
- nl2sql/cli/generators/llm/generator.py +24 -0
- nl2sql/cli/generators/llm/templates.py +4 -0
- nl2sql/cli/generators/policies/__init__.py +3 -0
- nl2sql/cli/generators/policies/generator.py +20 -0
- nl2sql/cli/generators/policies/templates.py +2 -0
- nl2sql/cli/main.py +195 -0
- nl2sql/cli/reporting.py +878 -0
- nl2sql/cli/types.py +13 -0
- nl2sql/common/__init__.py +1 -0
- nl2sql/common/cancellation.py +25 -0
- nl2sql/common/context.py +5 -0
- nl2sql/common/errors.py +109 -0
- nl2sql/common/event_logger.py +88 -0
- nl2sql/common/exceptions.py +3 -0
- nl2sql/common/logger.py +119 -0
- nl2sql/common/metrics.py +50 -0
- nl2sql/common/resilience.py +59 -0
- nl2sql/common/settings.py +195 -0
- nl2sql/configs/__init__.py +6 -0
- nl2sql/configs/datasources.py +10 -0
- nl2sql/configs/llm.py +36 -0
- nl2sql/configs/manager.py +176 -0
- nl2sql/configs/policies.py +14 -0
- nl2sql/configs/sample_questions.py +11 -0
- nl2sql/configs/secrets.py +11 -0
- nl2sql/context.py +106 -0
- nl2sql/datasources/__init__.py +21 -0
- nl2sql/datasources/discovery.py +28 -0
- nl2sql/datasources/models.py +21 -0
- nl2sql/datasources/protocols.py +3 -0
- nl2sql/datasources/registry.py +172 -0
- nl2sql/evaluation/__init__.py +6 -0
- nl2sql/evaluation/benchmark_runner.py +320 -0
- nl2sql/evaluation/evaluator.py +134 -0
- nl2sql/evaluation/types.py +22 -0
- nl2sql/execution/__init__.py +4 -0
- nl2sql/execution/artifacts/__init__.py +3 -0
- nl2sql/execution/artifacts/parquet.py +41 -0
- nl2sql/execution/artifacts/store.py +165 -0
- nl2sql/execution/contracts.py +57 -0
- nl2sql/execution/execution_store.py +25 -0
- nl2sql/execution/executor/__init__.py +3 -0
- nl2sql/execution/executor/sql_executor.py +116 -0
- nl2sql/indexing/__init__.py +7 -0
- nl2sql/indexing/chunk_builder.py +227 -0
- nl2sql/indexing/embeddings.py +180 -0
- nl2sql/indexing/enrichment_service.py +316 -0
- nl2sql/indexing/models.py +209 -0
- nl2sql/indexing/orchestrator.py +90 -0
- nl2sql/indexing/vector_store.py +422 -0
- nl2sql/llm/__init__.py +8 -0
- nl2sql/llm/models.py +10 -0
- nl2sql/llm/registry.py +214 -0
- nl2sql/pipeline/__init__.py +1 -0
- nl2sql/pipeline/graph.py +73 -0
- nl2sql/pipeline/graph_utils.py +141 -0
- nl2sql/pipeline/nodes/__init__.py +25 -0
- nl2sql/pipeline/nodes/aggregator/__init__.py +4 -0
- nl2sql/pipeline/nodes/aggregator/node.py +55 -0
- nl2sql/pipeline/nodes/aggregator/prompts.py +20 -0
- nl2sql/pipeline/nodes/aggregator/schemas.py +28 -0
- nl2sql/pipeline/nodes/answer_synthesizer/__init__.py +4 -0
- nl2sql/pipeline/nodes/answer_synthesizer/node.py +98 -0
- nl2sql/pipeline/nodes/answer_synthesizer/prompts.py +19 -0
- nl2sql/pipeline/nodes/answer_synthesizer/schemas.py +24 -0
- nl2sql/pipeline/nodes/ast_planner/__init__.py +4 -0
- nl2sql/pipeline/nodes/ast_planner/node.py +104 -0
- nl2sql/pipeline/nodes/ast_planner/prompts.py +138 -0
- nl2sql/pipeline/nodes/ast_planner/schemas.py +236 -0
- nl2sql/pipeline/nodes/datasource_resolver/__init__.py +4 -0
- nl2sql/pipeline/nodes/datasource_resolver/node.py +253 -0
- nl2sql/pipeline/nodes/datasource_resolver/schemas.py +21 -0
- nl2sql/pipeline/nodes/decomposer/__init__.py +3 -0
- nl2sql/pipeline/nodes/decomposer/node.py +219 -0
- nl2sql/pipeline/nodes/decomposer/prompts.py +96 -0
- nl2sql/pipeline/nodes/decomposer/schemas.py +143 -0
- nl2sql/pipeline/nodes/executor/__init__.py +3 -0
- nl2sql/pipeline/nodes/executor/node.py +107 -0
- nl2sql/pipeline/nodes/generator/__init__.py +4 -0
- nl2sql/pipeline/nodes/generator/node.py +267 -0
- nl2sql/pipeline/nodes/generator/schemas.py +13 -0
- nl2sql/pipeline/nodes/global_planner/__init__.py +4 -0
- nl2sql/pipeline/nodes/global_planner/node.py +186 -0
- nl2sql/pipeline/nodes/global_planner/schemas.py +101 -0
- nl2sql/pipeline/nodes/refiner/__init__.py +4 -0
- nl2sql/pipeline/nodes/refiner/node.py +132 -0
- nl2sql/pipeline/nodes/refiner/prompts.py +28 -0
- nl2sql/pipeline/nodes/refiner/schemas.py +13 -0
- nl2sql/pipeline/nodes/schema_retriever/__init__.py +3 -0
- nl2sql/pipeline/nodes/schema_retriever/node.py +252 -0
- nl2sql/pipeline/nodes/schema_retriever/schema.py +27 -0
- nl2sql/pipeline/nodes/validator/__init__.py +7 -0
- nl2sql/pipeline/nodes/validator/node.py +839 -0
- nl2sql/pipeline/nodes/validator/schemas.py +12 -0
- nl2sql/pipeline/pipeline_runner.py +72 -0
- nl2sql/pipeline/routes.py +72 -0
- nl2sql/pipeline/runtime.py +153 -0
- nl2sql/pipeline/state.py +92 -0
- nl2sql/pipeline/subgraphs/__init__.py +5 -0
- nl2sql/pipeline/subgraphs/schemas.py +23 -0
- nl2sql/pipeline/subgraphs/sql_agent.py +167 -0
- nl2sql/public_api.py +199 -0
- nl2sql/schema/__init__.py +37 -0
- nl2sql/schema/in_memory_store.py +173 -0
- nl2sql/schema/protocol.py +88 -0
- nl2sql/schema/sqlite_store.py +233 -0
- nl2sql/schema/store.py +29 -0
- nl2sql/secrets/__init__.py +14 -0
- nl2sql/secrets/factory.py +85 -0
- nl2sql/secrets/interfaces.py +16 -0
- nl2sql/secrets/manager.py +139 -0
- nl2sql/secrets/models.py +56 -0
- nl2sql/secrets/providers/aws.py +30 -0
- nl2sql/secrets/providers/azure.py +49 -0
- nl2sql/secrets/providers/env.py +8 -0
- nl2sql/secrets/providers/hashi.py +46 -0
- nl2sql/services/__init__.py +0 -0
- nl2sql/services/callbacks/__init__.py +0 -0
- nl2sql/services/callbacks/monitor.py +84 -0
- nl2sql/services/callbacks/node_context.py +7 -0
- nl2sql/services/callbacks/node_handlers.py +187 -0
- nl2sql/services/callbacks/node_metrics.py +14 -0
- nl2sql/services/callbacks/presenter.py +12 -0
- nl2sql/services/callbacks/token_handler.py +56 -0
- nl2sql_engine-0.1.0.dist-info/METADATA +295 -0
- nl2sql_engine-0.1.0.dist-info/RECORD +192 -0
- nl2sql_engine-0.1.0.dist-info/WHEEL +5 -0
- nl2sql_engine-0.1.0.dist-info/entry_points.txt +9 -0
- 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
|
+
|
nl2sql/secrets/models.py
ADDED
|
@@ -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
|