surfdataverse 4.1.1__tar.gz → 4.2.0__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.
- {surfdataverse-4.1.1 → surfdataverse-4.2.0}/PKG-INFO +2 -1
- {surfdataverse-4.1.1 → surfdataverse-4.2.0}/pyproject.toml +3 -1
- {surfdataverse-4.1.1 → surfdataverse-4.2.0}/src/surfdataverse/core.py +92 -54
- {surfdataverse-4.1.1 → surfdataverse-4.2.0}/README.md +0 -0
- {surfdataverse-4.1.1 → surfdataverse-4.2.0}/src/surfdataverse/__init__.py +0 -0
- {surfdataverse-4.1.1 → surfdataverse-4.2.0}/src/surfdataverse/container.py +0 -0
- {surfdataverse-4.1.1 → surfdataverse-4.2.0}/src/surfdataverse/exceptions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: surfdataverse
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: A Python package for ionysis Microsoft Dataverse integration
|
|
5
5
|
Keywords: dataverse,microsoft,crm,api
|
|
6
6
|
Author: ionysis
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.8
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.9
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Requires-Dist: azure-identity>=1.25.3
|
|
17
18
|
Requires-Dist: dependency-injector>=4.42.0
|
|
18
19
|
Requires-Dist: msal>=1.33.0
|
|
19
20
|
Requires-Dist: numpy>=2.3.3
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "surfdataverse"
|
|
7
|
-
version = "4.
|
|
7
|
+
version = "4.2.0"
|
|
8
8
|
description = "A Python package for ionysis Microsoft Dataverse integration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -24,6 +24,7 @@ classifiers = [
|
|
|
24
24
|
keywords = ["dataverse", "microsoft", "crm", "api"]
|
|
25
25
|
requires-python = ">=3.11"
|
|
26
26
|
dependencies = [
|
|
27
|
+
"azure-identity>=1.25.3",
|
|
27
28
|
"dependency-injector>=4.42.0",
|
|
28
29
|
"msal>=1.33.0",
|
|
29
30
|
"numpy>=2.3.3",
|
|
@@ -50,6 +51,7 @@ typing-test = [
|
|
|
50
51
|
]
|
|
51
52
|
dev = [
|
|
52
53
|
"ruff>=0.0.241",
|
|
54
|
+
"ty>=0.0.35",
|
|
53
55
|
"uv-build>=0.8.18",
|
|
54
56
|
]
|
|
55
57
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
from collections import defaultdict
|
|
2
1
|
import io
|
|
3
2
|
import json
|
|
4
3
|
import logging
|
|
5
4
|
import sys
|
|
5
|
+
import time
|
|
6
6
|
import uuid
|
|
7
7
|
from decimal import Decimal
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Optional
|
|
10
9
|
|
|
11
|
-
import msal
|
|
10
|
+
import msal
|
|
12
11
|
import pandas as pd
|
|
13
12
|
import requests
|
|
13
|
+
from azure.core.credentials import AccessToken
|
|
14
14
|
|
|
15
15
|
from .exceptions import (
|
|
16
16
|
AuthenticationError,
|
|
@@ -24,10 +24,10 @@ from .exceptions import (
|
|
|
24
24
|
class DecimalEncoder(json.JSONEncoder):
|
|
25
25
|
"""Custom JSON encoder to handle Decimal objects"""
|
|
26
26
|
|
|
27
|
-
def default(self,
|
|
28
|
-
if isinstance(
|
|
29
|
-
return float(
|
|
30
|
-
return super().default(
|
|
27
|
+
def default(self, o):
|
|
28
|
+
if isinstance(o, Decimal):
|
|
29
|
+
return float(o)
|
|
30
|
+
return super().default(o)
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
logger = logging.getLogger(__name__)
|
|
@@ -61,24 +61,69 @@ def is_valid_guid(value: str) -> bool:
|
|
|
61
61
|
return False
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
def get_token_cache():
|
|
65
|
+
"""Returns a singleton token cache to prevent cache conflicts between client instances."""
|
|
66
|
+
global _global_token_cache, _global_cache_file
|
|
67
|
+
|
|
68
|
+
# Return existing singleton if already initialized
|
|
69
|
+
if _global_token_cache is not None:
|
|
70
|
+
logger.debug(f"Returning existing token cache: {_global_cache_file}")
|
|
71
|
+
return _global_token_cache, _global_cache_file
|
|
72
|
+
|
|
73
|
+
# Initialize singleton cache
|
|
74
|
+
logger.info("Initializing singleton token cache...")
|
|
75
|
+
|
|
76
|
+
# Create app-specific config directory
|
|
77
|
+
app_config_dir = user_config_dir("SurfDataverse", "ionysis")
|
|
78
|
+
app_config_dir.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
# Token cache file path
|
|
81
|
+
_global_cache_file = app_config_dir / "token_cache.bin"
|
|
82
|
+
_global_token_cache = msal.SerializableTokenCache()
|
|
83
|
+
|
|
84
|
+
if _global_cache_file.exists():
|
|
85
|
+
logger.info(f"Found existing cache file: {_global_cache_file}")
|
|
86
|
+
try:
|
|
87
|
+
with open(_global_cache_file, "r") as f:
|
|
88
|
+
cache_content = f.read()
|
|
89
|
+
logger.info(f"Cache file size: {len(cache_content)} bytes")
|
|
90
|
+
if not cache_content.strip():
|
|
91
|
+
logger.warning("Cache file is empty, removing it")
|
|
92
|
+
_global_cache_file.unlink()
|
|
93
|
+
else:
|
|
94
|
+
_global_token_cache.deserialize(cache_content)
|
|
95
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
96
|
+
logger.warning(f"Corrupted cache file detected: {e}. Removing and starting fresh.")
|
|
97
|
+
_global_cache_file.unlink()
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.warning(f"Error reading cache file: {e}. Removing and starting fresh.")
|
|
100
|
+
_global_cache_file.unlink()
|
|
101
|
+
|
|
102
|
+
logger.info(f"Using singleton token cache: {_global_cache_file}")
|
|
103
|
+
return _global_token_cache, _global_cache_file
|
|
104
|
+
|
|
105
|
+
|
|
64
106
|
class DataverseClient:
|
|
65
|
-
def __init__(self, config_path=None):
|
|
107
|
+
def __init__(self, config_path: str | None = None):
|
|
66
108
|
self.connected = False
|
|
67
109
|
self.session = requests.Session()
|
|
68
110
|
self.environment_uri: str = ""
|
|
69
111
|
self.app = None # Store MSAL application instance
|
|
70
112
|
self.token_cache = None # Store the token cache
|
|
71
113
|
self.account = None # Store the logged-in account
|
|
72
|
-
self.config_path = config_path
|
|
114
|
+
self.config_path: str | None = config_path
|
|
73
115
|
|
|
74
116
|
# === CONNECTION, AUTHENTICATION, TEST
|
|
75
|
-
def get_authenticated_session(self, config_json:
|
|
117
|
+
def get_authenticated_session(self, config_json: str | Path | None = None):
|
|
76
118
|
if config_json is None and self.config_path is None:
|
|
77
119
|
raise ConfigurationError(
|
|
78
120
|
"No configuration file provided. Please specify config_json or set config_path during initialization."
|
|
79
121
|
)
|
|
80
122
|
|
|
81
123
|
config_file = config_json or self.config_path
|
|
124
|
+
if config_file is None:
|
|
125
|
+
raise ConfigurationError("Configuration file path is not set.")
|
|
126
|
+
|
|
82
127
|
logger.info(f"Loading config from: {config_file}")
|
|
83
128
|
config = json.load(open(config_file))
|
|
84
129
|
logger.info("Config loaded successfully")
|
|
@@ -88,7 +133,7 @@ class DataverseClient:
|
|
|
88
133
|
scope = [self.environment_uri + config["scopeSuffix"]]
|
|
89
134
|
|
|
90
135
|
logger.info("Getting token cache...")
|
|
91
|
-
self.token_cache, cache_file =
|
|
136
|
+
self.token_cache, cache_file = get_token_cache()
|
|
92
137
|
logger.info(f"Token cache initialized, cache file: {cache_file}")
|
|
93
138
|
self.app = msal.PublicClientApplication(
|
|
94
139
|
config["clientID"], authority=authority, token_cache=self.token_cache
|
|
@@ -102,6 +147,10 @@ class DataverseClient:
|
|
|
102
147
|
self.account = accounts[0]
|
|
103
148
|
logger.info("Acquiring token silently")
|
|
104
149
|
result = self.app.acquire_token_silent(scopes=scope, account=self.account)
|
|
150
|
+
if result is None:
|
|
151
|
+
logger.info("No token found in cache for the account, will need interactive login")
|
|
152
|
+
result = self.app.acquire_token_interactive(scope)
|
|
153
|
+
|
|
105
154
|
logger.info(
|
|
106
155
|
f"Silent token result: {result.keys() if isinstance(result, dict) else type(result)}"
|
|
107
156
|
)
|
|
@@ -134,48 +183,6 @@ class DataverseClient:
|
|
|
134
183
|
logger.error(f"Description: {result.get('error_description', 'No description')}")
|
|
135
184
|
raise AuthenticationError(error_msg)
|
|
136
185
|
|
|
137
|
-
@staticmethod
|
|
138
|
-
def get_token_cache():
|
|
139
|
-
"""Returns a singleton token cache to prevent cache conflicts between client instances."""
|
|
140
|
-
global _global_token_cache, _global_cache_file
|
|
141
|
-
|
|
142
|
-
# Return existing singleton if already initialized
|
|
143
|
-
if _global_token_cache is not None:
|
|
144
|
-
logger.debug(f"Returning existing token cache: {_global_cache_file}")
|
|
145
|
-
return _global_token_cache, _global_cache_file
|
|
146
|
-
|
|
147
|
-
# Initialize singleton cache
|
|
148
|
-
logger.info("Initializing singleton token cache...")
|
|
149
|
-
|
|
150
|
-
# Create app-specific config directory
|
|
151
|
-
app_config_dir = user_config_dir("SurfDataverse", "ionysis")
|
|
152
|
-
app_config_dir.mkdir(parents=True, exist_ok=True)
|
|
153
|
-
|
|
154
|
-
# Token cache file path
|
|
155
|
-
_global_cache_file = app_config_dir / "token_cache.bin"
|
|
156
|
-
_global_token_cache = msal.SerializableTokenCache()
|
|
157
|
-
|
|
158
|
-
if _global_cache_file.exists():
|
|
159
|
-
logger.info(f"Found existing cache file: {_global_cache_file}")
|
|
160
|
-
try:
|
|
161
|
-
with open(_global_cache_file, "r") as f:
|
|
162
|
-
cache_content = f.read()
|
|
163
|
-
logger.info(f"Cache file size: {len(cache_content)} bytes")
|
|
164
|
-
if not cache_content.strip():
|
|
165
|
-
logger.warning("Cache file is empty, removing it")
|
|
166
|
-
_global_cache_file.unlink()
|
|
167
|
-
else:
|
|
168
|
-
_global_token_cache.deserialize(cache_content)
|
|
169
|
-
except (json.JSONDecodeError, ValueError) as e:
|
|
170
|
-
logger.warning(f"Corrupted cache file detected: {e}. Removing and starting fresh.")
|
|
171
|
-
_global_cache_file.unlink()
|
|
172
|
-
except Exception as e:
|
|
173
|
-
logger.warning(f"Error reading cache file: {e}. Removing and starting fresh.")
|
|
174
|
-
_global_cache_file.unlink()
|
|
175
|
-
|
|
176
|
-
logger.info(f"Using singleton token cache: {_global_cache_file}")
|
|
177
|
-
return _global_token_cache, _global_cache_file
|
|
178
|
-
|
|
179
186
|
def test_connection(self):
|
|
180
187
|
request_uri = f"{self.environment_uri}api/data/v9.2/"
|
|
181
188
|
try:
|
|
@@ -192,6 +199,35 @@ class DataverseClient:
|
|
|
192
199
|
raise ConnectionError(f"Network error during connection test: {str(e)}")
|
|
193
200
|
|
|
194
201
|
|
|
202
|
+
class MsalStorageCredential:
|
|
203
|
+
"""Azure TokenCredential backed by MSAL.
|
|
204
|
+
|
|
205
|
+
If the user already authenticated for Dataverse in this session, acquiring a
|
|
206
|
+
storage token is silent (no browser popup). Otherwise the browser opens once.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(self, client_id: str, authority: str):
|
|
210
|
+
token_cache, _ = get_token_cache()
|
|
211
|
+
self._app = msal.PublicClientApplication(
|
|
212
|
+
client_id, authority=authority, token_cache=token_cache
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def get_token(self, *scopes, **kwargs) -> AccessToken:
|
|
216
|
+
scope = list(scopes) if scopes else [kwargs.get("scope")]
|
|
217
|
+
result = None
|
|
218
|
+
accounts = self._app.get_accounts()
|
|
219
|
+
if accounts:
|
|
220
|
+
result = self._app.acquire_token_silent(scopes=scope, account=accounts[0])
|
|
221
|
+
if not result:
|
|
222
|
+
result = self._app.acquire_token_interactive(scope)
|
|
223
|
+
if "access_token" not in result:
|
|
224
|
+
raise RuntimeError(
|
|
225
|
+
f"Blob storage authentication failed: {result.get('error_description', result.get('error'))}"
|
|
226
|
+
)
|
|
227
|
+
expires_on = int(time.time()) + result.get("expires_in", 3600)
|
|
228
|
+
return AccessToken(result["access_token"], expires_on)
|
|
229
|
+
|
|
230
|
+
|
|
195
231
|
class DataverseBase:
|
|
196
232
|
"""Base class for Dataverse operations with shared functionality"""
|
|
197
233
|
|
|
@@ -491,7 +527,9 @@ class DataverseTable(DataverseBase):
|
|
|
491
527
|
logger.error(error_msg)
|
|
492
528
|
raise DataverseAPIError(error_msg, response.status_code, response.text)
|
|
493
529
|
|
|
494
|
-
def get_column_data(
|
|
530
|
+
def get_column_data(
|
|
531
|
+
self, columns, entity_set_name=None, polars=False, infer_schema_length=None
|
|
532
|
+
):
|
|
495
533
|
"""Fetches specific column data from this table
|
|
496
534
|
Args:
|
|
497
535
|
columns: Column name (string) or list of column names to retrieve
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|