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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: surfdataverse
3
- Version: 4.1.1
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.1.1"
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 # type: ignore
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, obj):
28
- if isinstance(obj, Decimal):
29
- return float(obj)
30
- return super().default(obj)
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: Optional[str | Path] = None):
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 = self.get_token_cache()
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(self, columns, entity_set_name=None, polars=False, infer_schema_length=None):
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