surfdataverse 4.1.2__tar.gz → 4.2.1__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.2
3
+ Version: 4.2.1
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.2"
7
+ version = "4.2.1"
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
@@ -138,48 +183,6 @@ class DataverseClient:
138
183
  logger.error(f"Description: {result.get('error_description', 'No description')}")
139
184
  raise AuthenticationError(error_msg)
140
185
 
141
- @staticmethod
142
- def get_token_cache():
143
- """Returns a singleton token cache to prevent cache conflicts between client instances."""
144
- global _global_token_cache, _global_cache_file
145
-
146
- # Return existing singleton if already initialized
147
- if _global_token_cache is not None:
148
- logger.debug(f"Returning existing token cache: {_global_cache_file}")
149
- return _global_token_cache, _global_cache_file
150
-
151
- # Initialize singleton cache
152
- logger.info("Initializing singleton token cache...")
153
-
154
- # Create app-specific config directory
155
- app_config_dir = user_config_dir("SurfDataverse", "ionysis")
156
- app_config_dir.mkdir(parents=True, exist_ok=True)
157
-
158
- # Token cache file path
159
- _global_cache_file = app_config_dir / "token_cache.bin"
160
- _global_token_cache = msal.SerializableTokenCache()
161
-
162
- if _global_cache_file.exists():
163
- logger.info(f"Found existing cache file: {_global_cache_file}")
164
- try:
165
- with open(_global_cache_file, "r") as f:
166
- cache_content = f.read()
167
- logger.info(f"Cache file size: {len(cache_content)} bytes")
168
- if not cache_content.strip():
169
- logger.warning("Cache file is empty, removing it")
170
- _global_cache_file.unlink()
171
- else:
172
- _global_token_cache.deserialize(cache_content)
173
- except (json.JSONDecodeError, ValueError) as e:
174
- logger.warning(f"Corrupted cache file detected: {e}. Removing and starting fresh.")
175
- _global_cache_file.unlink()
176
- except Exception as e:
177
- logger.warning(f"Error reading cache file: {e}. Removing and starting fresh.")
178
- _global_cache_file.unlink()
179
-
180
- logger.info(f"Using singleton token cache: {_global_cache_file}")
181
- return _global_token_cache, _global_cache_file
182
-
183
186
  def test_connection(self):
184
187
  request_uri = f"{self.environment_uri}api/data/v9.2/"
185
188
  try:
@@ -196,6 +199,35 @@ class DataverseClient:
196
199
  raise ConnectionError(f"Network error during connection test: {str(e)}")
197
200
 
198
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
+
199
231
  class DataverseBase:
200
232
  """Base class for Dataverse operations with shared functionality"""
201
233
 
@@ -495,7 +527,9 @@ class DataverseTable(DataverseBase):
495
527
  logger.error(error_msg)
496
528
  raise DataverseAPIError(error_msg, response.status_code, response.text)
497
529
 
498
- 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
+ ):
499
533
  """Fetches specific column data from this table
500
534
  Args:
501
535
  columns: Column name (string) or list of column names to retrieve
File without changes