datasourcelib 0.1.5__tar.gz → 0.1.6__tar.gz
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.
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/PKG-INFO +1 -1
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/setup.py +2 -2
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/core/sync_manager.py +3 -1
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/datasource_types.py +2 -1
- datasourcelib-0.1.6/src/datasourcelib/datasources/dataverse_source.py +291 -0
- datasourcelib-0.1.6/src/datasourcelib/datasources/sql_source_bkup.py +159 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib.egg-info/PKG-INFO +1 -1
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib.egg-info/SOURCES.txt +2 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/LICENSE +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/README.md +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/pyproject.toml +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/setup.cfg +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/__init__.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/core/__init__.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/core/sync_base.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/core/sync_types.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/__init__.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/azure_devops_source copy.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/azure_devops_source.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/blob_source.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/datasource_base.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/sharepoint_source - Copy.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/sharepoint_source.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/sql_source.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/indexes/__init__.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/indexes/azure_search_index.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/strategies/__init__.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/strategies/daily_load.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/strategies/full_load.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/strategies/incremental_load.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/strategies/ondemand_load.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/strategies/timerange_load.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/utils/__init__.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/utils/byte_reader.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/utils/exceptions.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/utils/file_reader.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/utils/logger.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/utils/validators.py +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib.egg-info/dependency_links.txt +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib.egg-info/requires.txt +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib.egg-info/top_level.txt +0 -0
- {datasourcelib-0.1.5 → datasourcelib-0.1.6}/tests/test_sync_strategies.py +0 -0
|
@@ -2,8 +2,8 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="datasourcelib",
|
|
5
|
-
version="0.1.
|
|
6
|
-
packages=find_packages(where="src", exclude=["tests","examples"]),
|
|
5
|
+
version="0.1.6",
|
|
6
|
+
packages=find_packages(where="src", exclude=["tests.*", "tests", "examples.*", "examples"]),
|
|
7
7
|
package_dir={"": "src"},
|
|
8
8
|
install_requires=[
|
|
9
9
|
"fastapi>=0.68.0",
|
|
@@ -10,6 +10,7 @@ from ..datasources.sql_source import SQLDataSource
|
|
|
10
10
|
from ..datasources.azure_devops_source import AzureDevOpsSource
|
|
11
11
|
from ..datasources.sharepoint_source import SharePointSource
|
|
12
12
|
from ..datasources.blob_source import BlobStorageSource
|
|
13
|
+
from ..datasources.dataverse_source import DataverseSource
|
|
13
14
|
|
|
14
15
|
# concrete strategies
|
|
15
16
|
from datasourcelib.strategies.full_load import FullLoadStrategy
|
|
@@ -35,7 +36,8 @@ class SyncManager:
|
|
|
35
36
|
DataSourceType.SQL: SQLDataSource,
|
|
36
37
|
DataSourceType.AZURE_DEVOPS: AzureDevOpsSource,
|
|
37
38
|
DataSourceType.SHAREPOINT: SharePointSource,
|
|
38
|
-
DataSourceType.BLOB_STORAGE: BlobStorageSource
|
|
39
|
+
DataSourceType.BLOB_STORAGE: BlobStorageSource,
|
|
40
|
+
DataSourceType.Dataverse: DataverseSource
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
def execute_sync(self, sync_type: SyncType,
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
2
|
+
from datasourcelib.datasources.datasource_base import DataSourceBase
|
|
3
|
+
from datasourcelib.utils.logger import get_logger
|
|
4
|
+
from datasourcelib.utils.validators import require_keys
|
|
5
|
+
import pyodbc
|
|
6
|
+
import time
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
# optional requests import (webapi mode)
|
|
10
|
+
try:
|
|
11
|
+
import requests # type: ignore
|
|
12
|
+
except Exception:
|
|
13
|
+
requests = None # lazy import
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
class DataverseSource(DataSourceBase):
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: Dict[str, Any]):
|
|
20
|
+
super().__init__(config)
|
|
21
|
+
self._conn = None
|
|
22
|
+
self._mode = (self.config.get("dv_mode") or "tds").lower() # "tds" or "webapi"
|
|
23
|
+
self._access_token: Optional[str] = None
|
|
24
|
+
self._headers: Dict[str, str] = {}
|
|
25
|
+
self._max_retries = int(self.config.get("dv_max_retries", 3))
|
|
26
|
+
|
|
27
|
+
def validate_config(self) -> bool:
|
|
28
|
+
"""
|
|
29
|
+
Validate required keys depending on selected dv_mode.
|
|
30
|
+
- tds: requires either 'tds_connection_string' OR ('dataverse_server' and 'dataverse_database')
|
|
31
|
+
- webapi: requires 'webapi_url','client_id','client_secret','tenant_id' (or 'resource')
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
if self._mode == "webapi":
|
|
35
|
+
require_keys(self.config, ["dv_webapi_url", "dv_webapi_client_id", "dv_webapi_client_secret", "dv_webapi_tenant_id"])
|
|
36
|
+
else:
|
|
37
|
+
# TDS mode (ODBC)
|
|
38
|
+
if "dv_tds_connection_string" in self.config:
|
|
39
|
+
return True
|
|
40
|
+
# otherwise require components
|
|
41
|
+
require_keys(self.config, ["dv_tds_server", "dv_tds_database"])
|
|
42
|
+
# if not using integrated auth require creds
|
|
43
|
+
if not bool(self.config.get("dv_tds_windows_auth", False)):
|
|
44
|
+
require_keys(self.config, ["dv_tds_username", "dv_tds_password"])
|
|
45
|
+
return True
|
|
46
|
+
except Exception as ex:
|
|
47
|
+
logger.error("DataverseSource.validate_config failed: %s", ex)
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
# -------------------------
|
|
51
|
+
# Connection helpers
|
|
52
|
+
# -------------------------
|
|
53
|
+
def _get_available_driver(self) -> str:
|
|
54
|
+
"""Return first suitable ODBC driver for SQL/Dataverse TDS access."""
|
|
55
|
+
preferred_drivers = [
|
|
56
|
+
"ODBC Driver 18 for SQL Server",
|
|
57
|
+
"ODBC Driver 17 for SQL Server",
|
|
58
|
+
"SQL Server Native Client 11.0",
|
|
59
|
+
"SQL Server"
|
|
60
|
+
]
|
|
61
|
+
try:
|
|
62
|
+
drivers = pyodbc.drivers()
|
|
63
|
+
logger.info("Available ODBC drivers: %s", drivers)
|
|
64
|
+
|
|
65
|
+
for d in preferred_drivers:
|
|
66
|
+
if d in drivers:
|
|
67
|
+
logger.info("Using ODBC driver: %s", d)
|
|
68
|
+
return d
|
|
69
|
+
|
|
70
|
+
# fallback to first available
|
|
71
|
+
if drivers:
|
|
72
|
+
logger.warning("No preferred driver found. Using: %s", drivers[0])
|
|
73
|
+
return drivers[0]
|
|
74
|
+
raise RuntimeError("No ODBC drivers available")
|
|
75
|
+
except Exception as ex:
|
|
76
|
+
logger.error("DataverseSource._get_available_driver failed: %s", ex)
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
def _build_tds_conn_str(self) -> str:
|
|
80
|
+
"""Build valid connection string with proper parameter names."""
|
|
81
|
+
if "dv_tds_connection_string" in self.config:
|
|
82
|
+
return self.config["dv_tds_connection_string"]
|
|
83
|
+
|
|
84
|
+
driver = self._get_available_driver()
|
|
85
|
+
# Fix: use correct config key names (dv_tds_server, not dv_tds_dataverse_server)
|
|
86
|
+
server = self.config.get("dv_tds_server", "").strip()
|
|
87
|
+
database = self.config.get("dv_tds_database", "").strip()
|
|
88
|
+
|
|
89
|
+
if not server:
|
|
90
|
+
raise ValueError("dv_tds_server are required")
|
|
91
|
+
|
|
92
|
+
logger.info("Building TDS connection (driver=%s, server=%s, database=%s)", driver, server, database)
|
|
93
|
+
|
|
94
|
+
# Use curly braces for driver name (handles spaces in driver names)
|
|
95
|
+
parts = [f"DRIVER={{{driver}}}"]
|
|
96
|
+
parts.append(f"Server={server}")
|
|
97
|
+
parts.append(f"Database={database}")
|
|
98
|
+
password = None
|
|
99
|
+
if bool(self.config.get("dv_tds_windows_auth", False)):
|
|
100
|
+
parts.append("Trusted_Connection=yes")
|
|
101
|
+
logger.info("Using Windows authentication")
|
|
102
|
+
else:
|
|
103
|
+
username = self.config.get("dv_tds_username", "").strip()
|
|
104
|
+
password = self.config.get("dv_tds_password", "").strip()
|
|
105
|
+
|
|
106
|
+
if not username or not password:
|
|
107
|
+
raise ValueError("dv_tds_username and dv_tds_password required when Windows auth disabled")
|
|
108
|
+
|
|
109
|
+
parts.append(f"UID={username}")
|
|
110
|
+
parts.append(f"PWD={password}")
|
|
111
|
+
parts.append("Authentication=ActiveDirectoryInteractive")
|
|
112
|
+
# Encryption settings
|
|
113
|
+
if not bool(self.config.get("dv_tds_is_onprem", False)):
|
|
114
|
+
parts.append("Encrypt=yes")
|
|
115
|
+
parts.append("TrustServerCertificate=no")
|
|
116
|
+
else:
|
|
117
|
+
parts.append("Encrypt=optional")
|
|
118
|
+
parts.append("TrustServerCertificate=yes")
|
|
119
|
+
|
|
120
|
+
conn_str = ";".join(parts)
|
|
121
|
+
logger.debug("Connection string: %s", conn_str.replace(password or "", "***") if password else conn_str)
|
|
122
|
+
return conn_str
|
|
123
|
+
|
|
124
|
+
def _obtain_webapi_token(self) -> Tuple[str, Dict[str, str]]:
|
|
125
|
+
"""
|
|
126
|
+
Acquire OAuth2 token using client credentials flow.
|
|
127
|
+
Returns (access_token, headers)
|
|
128
|
+
Config expected keys: tenant_id, client_id, client_secret, optional resource
|
|
129
|
+
"""
|
|
130
|
+
if requests is None:
|
|
131
|
+
raise RuntimeError("requests package required for Dataverse Web API mode")
|
|
132
|
+
tenant = self.config["dv_webapi_tenant_id"]
|
|
133
|
+
client_id = self.config["dv_webapi_client_id"]
|
|
134
|
+
client_secret = self.config["dv_webapi_client_secret"]
|
|
135
|
+
# resource or scope: prefer explicit resource, else fallback to webapi_url host
|
|
136
|
+
resource = self.config.get("dv_webapi_resource")
|
|
137
|
+
if not resource:
|
|
138
|
+
# infer resource from webapi_url e.g. https://<org>.crm.dynamics.com
|
|
139
|
+
webapi_url = self.config["dv_webapi_url"].rstrip("/")
|
|
140
|
+
resource = webapi_url.split("://")[-1]
|
|
141
|
+
resource = f"https://{resource}" # as resource
|
|
142
|
+
token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
|
143
|
+
data = {
|
|
144
|
+
"grant_type": "client_credentials",
|
|
145
|
+
"client_id": client_id,
|
|
146
|
+
"client_secret": client_secret,
|
|
147
|
+
"scope": f"{resource}/.default"
|
|
148
|
+
}
|
|
149
|
+
resp = requests.post(token_url, data=data, timeout=30)
|
|
150
|
+
resp.raise_for_status()
|
|
151
|
+
j = resp.json()
|
|
152
|
+
token = j.get("access_token")
|
|
153
|
+
if not token:
|
|
154
|
+
raise RuntimeError("Failed to obtain access token for Dataverse webapi")
|
|
155
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json", "OData-MaxVersion": "4.0", "OData-Version": "4.0"}
|
|
156
|
+
return token, headers
|
|
157
|
+
|
|
158
|
+
# -------------------------
|
|
159
|
+
# Public connection API
|
|
160
|
+
# -------------------------
|
|
161
|
+
def connect(self) -> bool:
|
|
162
|
+
try:
|
|
163
|
+
if self._mode == "webapi":
|
|
164
|
+
token, headers = self._obtain_webapi_token()
|
|
165
|
+
self._access_token = token
|
|
166
|
+
self._headers = headers
|
|
167
|
+
self._connected = True
|
|
168
|
+
logger.info("DataverseSource connected (webapi mode) to %s", self.config.get("dv_webapi_url"))
|
|
169
|
+
return True
|
|
170
|
+
# else TDS mode
|
|
171
|
+
conn_str = self._build_tds_conn_str()
|
|
172
|
+
self._conn = pyodbc.connect(conn_str, timeout=int(self.config.get("dv_tds_timeout", 30)))
|
|
173
|
+
self._connected = True
|
|
174
|
+
logger.info("DataverseSource connected (dv_tds mode) to %s/%s", self.config.get("dv_server"), self.config.get("dv_database"))
|
|
175
|
+
return True
|
|
176
|
+
except pyodbc.Error as ex:
|
|
177
|
+
logger.error("DataverseSource.connect failed - ODBC Error: %s", ex)
|
|
178
|
+
self._connected = False
|
|
179
|
+
return False
|
|
180
|
+
except requests.RequestException as ex:
|
|
181
|
+
logger.error("DataverseSource.connect failed - HTTP Error: %s", ex)
|
|
182
|
+
self._connected = False
|
|
183
|
+
return False
|
|
184
|
+
except Exception as ex:
|
|
185
|
+
logger.exception("DataverseSource.connect failed")
|
|
186
|
+
self._connected = False
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
def disconnect(self) -> None:
|
|
190
|
+
try:
|
|
191
|
+
if self._conn:
|
|
192
|
+
try:
|
|
193
|
+
self._conn.close()
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
self._conn = None
|
|
197
|
+
self._access_token = None
|
|
198
|
+
self._headers = {}
|
|
199
|
+
finally:
|
|
200
|
+
self._connected = False
|
|
201
|
+
logger.info("DataverseSource disconnected")
|
|
202
|
+
|
|
203
|
+
# -------------------------
|
|
204
|
+
# Data fetch
|
|
205
|
+
# -------------------------
|
|
206
|
+
def fetch_data(self, query: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
|
|
207
|
+
"""
|
|
208
|
+
Fetch rows from Dataverse.
|
|
209
|
+
- TDS mode: executes SQL query (config key 'tds_query' or provided 'query')
|
|
210
|
+
- WebAPI mode: calls Dataverse Web API path fragment (e.g. 'accounts?$select=name') or uses 'entity_set' + query params
|
|
211
|
+
Returns list[dict].
|
|
212
|
+
"""
|
|
213
|
+
attempt = 0
|
|
214
|
+
while attempt < self._max_retries:
|
|
215
|
+
try:
|
|
216
|
+
if not getattr(self, "_connected", False):
|
|
217
|
+
ok = self.connect()
|
|
218
|
+
if not ok:
|
|
219
|
+
raise RuntimeError("DataverseSource: cannot connect")
|
|
220
|
+
|
|
221
|
+
if self._mode == "webapi":
|
|
222
|
+
if requests is None:
|
|
223
|
+
raise RuntimeError("requests package required for webapi mode")
|
|
224
|
+
webapi_url = self.config["dv_webapi_url"].rstrip("/")
|
|
225
|
+
# if query provided, treat it as path fragment; else use entity_set from config
|
|
226
|
+
path_fragment = query or self.config.get("dv_webapi_entity_set")
|
|
227
|
+
if not path_fragment:
|
|
228
|
+
raise ValueError("DataverseSource.fetch_data requires a webapi 'query' or 'entity_set' in config")
|
|
229
|
+
url = f"{webapi_url}/api/data/v9.1/{path_fragment.lstrip('/')}"
|
|
230
|
+
params = kwargs.get("params")
|
|
231
|
+
resp = requests.get(url, headers=self._headers, params=params, timeout=60)
|
|
232
|
+
resp.raise_for_status()
|
|
233
|
+
j = resp.json()
|
|
234
|
+
items: Any = []
|
|
235
|
+
# Dataverse OData responses typically use 'value' for collections
|
|
236
|
+
if isinstance(j, dict) and "value" in j:
|
|
237
|
+
items = j["value"]
|
|
238
|
+
# otherwise return the raw json wrapped in a list or as-is
|
|
239
|
+
elif isinstance(j, list):
|
|
240
|
+
items= j
|
|
241
|
+
else:
|
|
242
|
+
items= [j]
|
|
243
|
+
|
|
244
|
+
df = pd.DataFrame(items)
|
|
245
|
+
# filter columns if configured
|
|
246
|
+
keep = self.config.get("dv_webapi_columns_to_keep")
|
|
247
|
+
if isinstance(keep, list) and keep:
|
|
248
|
+
cols_to_keep = [c for c in df.columns if c in keep]
|
|
249
|
+
else:
|
|
250
|
+
# exclude SharePoint metadata columns (start with '__' or prefixed with '@')
|
|
251
|
+
cols_to_keep = [c for c in df.columns if not str(c).startswith("__") and not str(c).startswith("@")]
|
|
252
|
+
df = df[cols_to_keep]
|
|
253
|
+
results = df.to_dict("records")
|
|
254
|
+
return results
|
|
255
|
+
# else TDS mode
|
|
256
|
+
sql = query or self.config.get("dv_tds_query") or self.config.get("dv_sql_query")
|
|
257
|
+
if not sql:
|
|
258
|
+
raise ValueError("DataverseSource.fetch_data requires a SQL query (tds mode)")
|
|
259
|
+
|
|
260
|
+
cur = self._conn.cursor()
|
|
261
|
+
try:
|
|
262
|
+
cur.execute(sql)
|
|
263
|
+
cols = [c[0] for c in (cur.description or [])]
|
|
264
|
+
rows = cur.fetchall()
|
|
265
|
+
results: List[Dict[str, Any]] = []
|
|
266
|
+
for r in rows:
|
|
267
|
+
results.append({cols[i]: r[i] for i in range(len(cols))})
|
|
268
|
+
return results
|
|
269
|
+
finally:
|
|
270
|
+
try:
|
|
271
|
+
cur.close()
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
except Exception as ex:
|
|
276
|
+
attempt += 1
|
|
277
|
+
logger.warning("DataverseSource.fetch_data attempt %d/%d failed: %s", attempt, self._max_retries, ex)
|
|
278
|
+
# transient retry for network/connection errors
|
|
279
|
+
if attempt >= self._max_retries:
|
|
280
|
+
logger.exception("DataverseSource.fetch_data final failure")
|
|
281
|
+
raise
|
|
282
|
+
# backoff
|
|
283
|
+
time.sleep(min(2 ** attempt, 10))
|
|
284
|
+
# try reconnect for next attempt
|
|
285
|
+
try:
|
|
286
|
+
self.disconnect()
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
# unreachable; defensive
|
|
291
|
+
return []
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
from datasourcelib.datasources.datasource_base import DataSourceBase
|
|
3
|
+
from datasourcelib.utils.logger import get_logger
|
|
4
|
+
from datasourcelib.utils.validators import require_keys
|
|
5
|
+
import os
|
|
6
|
+
import pyodbc
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
class SQLDataSource(DataSourceBase):
|
|
12
|
+
|
|
13
|
+
def __init__(self, config: Dict[str, Any]):
|
|
14
|
+
super().__init__(config)
|
|
15
|
+
self._conn = None
|
|
16
|
+
self._is_sqlite = False
|
|
17
|
+
|
|
18
|
+
def validate_config(self) -> bool:
|
|
19
|
+
"""
|
|
20
|
+
Validate config. If sql_windows_auth is True then sql_username/sql_password are optional.
|
|
21
|
+
Otherwise require sql_username and sql_password.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
# Always require server/database at minimum
|
|
25
|
+
require_keys(self.config, ["sql_server", "sql_database"])
|
|
26
|
+
# If not using Windows authentication, require credentials
|
|
27
|
+
if not bool(self.config.get("sql_windows_auth", False)):
|
|
28
|
+
require_keys(self.config, ["sql_username", "sql_password"])
|
|
29
|
+
return True
|
|
30
|
+
except Exception as ex:
|
|
31
|
+
logger.error("SQLDataSource.validate_config: %s", ex)
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def connect(self) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
sql_server = self.config.get("sql_server", "")
|
|
37
|
+
sql_database = self.config.get("sql_database", "")
|
|
38
|
+
sql_is_onprem = self.config.get("sql_is_onprem", False)
|
|
39
|
+
|
|
40
|
+
# Determine auth mode: sql_windows_auth (Trusted Connection) overrides username/password
|
|
41
|
+
sql_windows_auth = bool(self.config.get("sql_windows_auth", False))
|
|
42
|
+
|
|
43
|
+
# Get available driver
|
|
44
|
+
sql_driver = self._get_available_driver()
|
|
45
|
+
|
|
46
|
+
# Build connection string
|
|
47
|
+
conn_params = [
|
|
48
|
+
f'DRIVER={sql_driver}',
|
|
49
|
+
f'SERVER={sql_server}',
|
|
50
|
+
f'DATABASE={sql_database}',
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
if sql_windows_auth:
|
|
54
|
+
# Use integrated Windows authentication (Trusted Connection)
|
|
55
|
+
# This will use the current process credentials / kerberos ticket.
|
|
56
|
+
conn_params.append('Trusted_Connection=yes')
|
|
57
|
+
logger.info("SQLDataSource using Windows (integrated) authentication")
|
|
58
|
+
else:
|
|
59
|
+
sql_username = self.config.get("sql_username", "")
|
|
60
|
+
sql_password = self.config.get("sql_password", "")
|
|
61
|
+
conn_params.extend([f'UID={sql_username}', f'PWD={sql_password}'])
|
|
62
|
+
|
|
63
|
+
# Add encryption settings based on environment
|
|
64
|
+
if not sql_is_onprem:
|
|
65
|
+
conn_params.extend([
|
|
66
|
+
'Encrypt=yes',
|
|
67
|
+
'TrustServerCertificate=no'
|
|
68
|
+
])
|
|
69
|
+
else:
|
|
70
|
+
conn_params.extend([
|
|
71
|
+
'Encrypt=optional',
|
|
72
|
+
'TrustServerCertificate=yes'
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
conn_str = ';'.join(conn_params)
|
|
76
|
+
|
|
77
|
+
# Attempt connection with timeout
|
|
78
|
+
self._conn = pyodbc.connect(conn_str, timeout=30)
|
|
79
|
+
self._connected = True
|
|
80
|
+
logger.info("SQLDataSource connected to %s using driver %s (sql_windows_auth=%s)", sql_server, sql_driver, sql_windows_auth)
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
except pyodbc.Error as ex:
|
|
84
|
+
logger.error("SQLDataSource.connect failed - ODBC Error: %s", ex)
|
|
85
|
+
self._connected = False
|
|
86
|
+
return False
|
|
87
|
+
except Exception as ex:
|
|
88
|
+
logger.error("SQLDataSource.connect failed - Unexpected Error: %s", ex)
|
|
89
|
+
self._connected = False
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def disconnect(self) -> None:
|
|
93
|
+
try:
|
|
94
|
+
if self._conn:
|
|
95
|
+
self._conn.close()
|
|
96
|
+
finally:
|
|
97
|
+
self._conn = None
|
|
98
|
+
self._connected = False
|
|
99
|
+
logger.info("SQLDataSource disconnected")
|
|
100
|
+
|
|
101
|
+
def fetch_data(self, query: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
|
|
102
|
+
max_retries = 3
|
|
103
|
+
retry_count = 0
|
|
104
|
+
|
|
105
|
+
while retry_count < max_retries:
|
|
106
|
+
try:
|
|
107
|
+
if not self._connected:
|
|
108
|
+
ok = self.connect()
|
|
109
|
+
if not ok:
|
|
110
|
+
raise RuntimeError("SQLDataSource: not connected and cannot connect")
|
|
111
|
+
|
|
112
|
+
query = self.config.get("sql_query")
|
|
113
|
+
if not query:
|
|
114
|
+
raise ValueError("SQLDataSource.fetch_data requires a query")
|
|
115
|
+
|
|
116
|
+
cur = self._conn.cursor()
|
|
117
|
+
try:
|
|
118
|
+
cur.execute(query)
|
|
119
|
+
cols = [d[0] if hasattr(d, "__len__") else d[0] for d in (cur.description or [])]
|
|
120
|
+
rows = cur.fetchall()
|
|
121
|
+
results: List[Dict[str, Any]] = []
|
|
122
|
+
for r in rows:
|
|
123
|
+
results.append({cols[i]: r[i] for i in range(len(cols))})
|
|
124
|
+
return results
|
|
125
|
+
finally:
|
|
126
|
+
try:
|
|
127
|
+
cur.close()
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
except pyodbc.OperationalError as ex:
|
|
132
|
+
# Handle connection lost
|
|
133
|
+
retry_count += 1
|
|
134
|
+
logger.warning("Connection lost, attempt %d of %d: %s", retry_count, max_retries, ex)
|
|
135
|
+
self.disconnect()
|
|
136
|
+
if retry_count >= max_retries:
|
|
137
|
+
raise
|
|
138
|
+
except Exception as ex:
|
|
139
|
+
logger.error("Query execution failed: %s", ex)
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
def _get_available_driver(self) -> str:
|
|
143
|
+
"""Get first available SQL Server driver from preferred list."""
|
|
144
|
+
preferred_drivers = [
|
|
145
|
+
'ODBC Driver 18 for SQL Server',
|
|
146
|
+
'ODBC Driver 17 for SQL Server',
|
|
147
|
+
'SQL Server Native Client 11.0',
|
|
148
|
+
'SQL Server'
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
available_drivers = pyodbc.drivers()
|
|
153
|
+
for driver in preferred_drivers:
|
|
154
|
+
if driver in available_drivers:
|
|
155
|
+
return driver
|
|
156
|
+
raise RuntimeError(f"No suitable SQL Server driver found. Available drivers: {available_drivers}")
|
|
157
|
+
except Exception as ex:
|
|
158
|
+
logger.error("Failed to get SQL drivers: %s", ex)
|
|
159
|
+
raise
|
|
@@ -18,9 +18,11 @@ src/datasourcelib/datasources/azure_devops_source.py
|
|
|
18
18
|
src/datasourcelib/datasources/blob_source.py
|
|
19
19
|
src/datasourcelib/datasources/datasource_base.py
|
|
20
20
|
src/datasourcelib/datasources/datasource_types.py
|
|
21
|
+
src/datasourcelib/datasources/dataverse_source.py
|
|
21
22
|
src/datasourcelib/datasources/sharepoint_source - Copy.py
|
|
22
23
|
src/datasourcelib/datasources/sharepoint_source.py
|
|
23
24
|
src/datasourcelib/datasources/sql_source.py
|
|
25
|
+
src/datasourcelib/datasources/sql_source_bkup.py
|
|
24
26
|
src/datasourcelib/indexes/__init__.py
|
|
25
27
|
src/datasourcelib/indexes/azure_search_index.py
|
|
26
28
|
src/datasourcelib/strategies/__init__.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/azure_devops_source.py
RENAMED
|
File without changes
|
|
File without changes
|
{datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/datasource_base.py
RENAMED
|
File without changes
|
|
File without changes
|
{datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/datasources/sharepoint_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datasourcelib-0.1.5 → datasourcelib-0.1.6}/src/datasourcelib/strategies/incremental_load.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|