d365fo-client 0.2.2__py3-none-any.whl → 0.2.4__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.
d365fo_client/auth.py CHANGED
@@ -5,6 +5,7 @@ from typing import Optional
5
5
 
6
6
  from azure.identity import ClientSecretCredential, DefaultAzureCredential
7
7
 
8
+ from .credential_sources import CredentialManager, CredentialSource
8
9
  from .models import FOClientConfig
9
10
 
10
11
 
@@ -20,25 +21,46 @@ class AuthenticationManager:
20
21
  self.config = config
21
22
  self._token = None
22
23
  self._token_expires = None
23
- self.credential = self._setup_credentials()
24
-
25
- def _setup_credentials(self):
26
- """Setup authentication credentials"""
27
- if self.config.use_default_credentials:
28
- return DefaultAzureCredential()
29
- elif (
24
+ self._credential_manager = CredentialManager()
25
+ self.credential = None # Will be set by _setup_credentials
26
+
27
+ async def _setup_credentials(self):
28
+ """Setup authentication credentials with support for credential sources"""
29
+
30
+ # Check if credential source is specified in config
31
+ credential_source = getattr(self.config, 'credential_source', None)
32
+
33
+ if credential_source is not None:
34
+ # Use credential source to get credentials
35
+ try:
36
+ client_id, client_secret, tenant_id = await self._credential_manager.get_credentials(credential_source)
37
+ self.credential = ClientSecretCredential(
38
+ tenant_id=tenant_id,
39
+ client_id=client_id,
40
+ client_secret=client_secret,
41
+ )
42
+ return
43
+ except Exception as e:
44
+ raise ValueError(f"Failed to setup credentials from source: {e}")
45
+
46
+
47
+ # Fallback to existing logic for backward compatibility
48
+
49
+ if (
30
50
  self.config.client_id
31
51
  and self.config.client_secret
32
52
  and self.config.tenant_id
33
53
  ):
34
- return ClientSecretCredential(
54
+ self.credential = ClientSecretCredential(
35
55
  tenant_id=self.config.tenant_id,
36
56
  client_id=self.config.client_id,
37
57
  client_secret=self.config.client_secret,
38
58
  )
59
+ elif self.config.use_default_credentials:
60
+ self.credential = DefaultAzureCredential()
39
61
  else:
40
62
  raise ValueError(
41
- "Must provide either use_default_credentials=True or client credentials"
63
+ "Must provide either use_default_credentials=True, client credentials, or credential_source"
42
64
  )
43
65
 
44
66
  async def get_token(self) -> str:
@@ -51,6 +73,10 @@ class AuthenticationManager:
51
73
  if self._is_localhost():
52
74
  return "mock-token-for-localhost"
53
75
 
76
+ # Initialize credentials if not already set
77
+ if self.credential is None:
78
+ await self._setup_credentials()
79
+
54
80
  if (
55
81
  self._token
56
82
  and self._token_expires
@@ -91,3 +117,16 @@ class AuthenticationManager:
91
117
  """Invalidate cached token to force refresh"""
92
118
  self._token = None
93
119
  self._token_expires = None
120
+
121
+ async def invalidate_credentials(self):
122
+ """Invalidate cached credentials and token to force full refresh"""
123
+ self.invalidate_token()
124
+ self.credential = None
125
+ if hasattr(self, '_credential_manager'):
126
+ self._credential_manager.clear_cache()
127
+
128
+ def get_credential_cache_stats(self) -> dict:
129
+ """Get credential cache statistics for debugging"""
130
+ if hasattr(self, '_credential_manager'):
131
+ return self._credential_manager.get_cache_stats()
132
+ return {"total_cached": 0, "expired": 0, "active": 0}
d365fo_client/client.py CHANGED
@@ -11,6 +11,7 @@ from .exceptions import FOClientError
11
11
  from .labels import LabelOperations, resolve_labels_generic
12
12
  from .metadata_api import MetadataAPIOperations
13
13
  from .metadata_v2 import MetadataCacheV2, SmartSyncManagerV2
14
+ from .metadata_v2.sync_session_manager import SyncSessionManager
14
15
  from .models import (
15
16
  ActionInfo,
16
17
  DataEntityInfo,
@@ -58,6 +59,7 @@ class FOClient:
58
59
  # Initialize new metadata cache and sync components
59
60
  self.metadata_cache = None
60
61
  self.sync_manager = None
62
+ self._sync_session_manager = None
61
63
  self._metadata_initialized = False
62
64
  self._background_sync_task = None
63
65
 
@@ -83,6 +85,10 @@ class FOClient:
83
85
 
84
86
  await self.session_manager.close()
85
87
 
88
+
89
+ async def initialize_metadata(self):
90
+ await self._ensure_metadata_initialized()
91
+
86
92
  async def _ensure_metadata_initialized(self):
87
93
  """Ensure metadata cache and sync manager are initialized"""
88
94
  if not self._metadata_initialized and self.config.enable_metadata_cache:
@@ -104,6 +110,9 @@ class FOClient:
104
110
  self.metadata_cache, self.metadata_api_ops
105
111
  )
106
112
 
113
+ # Initialize sync message with session
114
+ self._sync_session_manager = SyncSessionManager(self.metadata_cache, self.metadata_api_ops)
115
+
107
116
  self._metadata_initialized = True
108
117
  self.logger.debug("Metadata cache v2 with label caching initialized")
109
118
 
@@ -144,18 +153,9 @@ class FOClient:
144
153
  f"Starting background metadata sync for version {global_version_id}"
145
154
  )
146
155
 
147
- # Use self as the fo_client for sync - SmartSyncManagerV2 expects a client with metadata API operations
148
- result = await self.sync_manager.sync_metadata(global_version_id)
149
-
150
- if result.success:
151
- self.logger.info(
152
- f"Background sync completed: "
153
- f"{result.entity_count} entities, "
154
- f"{result.enumeration_count} enumerations, "
155
- f"{result.duration_ms:.2f}ms"
156
- )
157
- else:
158
- self.logger.warning(f"Background sync failed: {result.error}")
156
+
157
+ self.sync_session_manager.start_sync_session(global_version_id=global_version_id,initiated_by="background_task")
158
+
159
159
 
160
160
  except Exception as e:
161
161
  self.logger.error(f"Background sync error: {e}")
@@ -172,6 +172,27 @@ class FOClient:
172
172
  and not self._background_sync_task.done()
173
173
  )
174
174
 
175
+ @property
176
+ def sync_session_manager(self) -> SyncSessionManager:
177
+ """Get sync session manager (lazy initialization).
178
+
179
+ Returns:
180
+ SyncSessionManager instance for enhanced sync progress tracking
181
+
182
+ Raises:
183
+ RuntimeError: If metadata cache is not initialized
184
+ """
185
+ if self._sync_session_manager is None:
186
+ if not self.metadata_cache:
187
+ raise RuntimeError("Metadata cache must be initialized before accessing sync session manager")
188
+
189
+ self._sync_session_manager = SyncSessionManager(
190
+ cache=self.metadata_cache,
191
+ metadata_api=self.metadata_api_ops
192
+ )
193
+
194
+ return self._sync_session_manager
195
+
175
196
  async def _get_from_cache_first(
176
197
  self,
177
198
  cache_method,
@@ -687,17 +708,16 @@ class FOClient:
687
708
 
688
709
  async def get_data_entities(
689
710
  self, options: Optional[QueryOptions] = None
690
- ) -> List[DataEntityInfo]:
691
- """Get data entities - updated to return list for v2 sync compatibility
711
+ ) -> Dict[str, Any]:
712
+ """Get data entities from DataEntities metadata endpoint
692
713
 
693
714
  Args:
694
- options: OData query options (ignored for now)
715
+ options: OData query options
695
716
 
696
717
  Returns:
697
- List of DataEntityInfo objects
718
+ Response containing data entities
698
719
  """
699
- # For sync manager compatibility, return list of DataEntityInfo objects
700
- return await self.metadata_api_ops.search_data_entities("") # Get all entities
720
+ return await self.metadata_api_ops.get_data_entities(options)
701
721
 
702
722
  async def get_data_entities_raw(
703
723
  self, options: Optional[QueryOptions] = None
@@ -934,7 +954,7 @@ class FOClient:
934
954
  Returns:
935
955
  Response containing public enumerations
936
956
  """
937
- self._ensure_metadata_initialized()
957
+ await self._ensure_metadata_initialized()
938
958
 
939
959
  return await self.metadata_api_ops.get_public_enumerations(options)
940
960
 
@@ -1188,7 +1208,7 @@ class FOClient:
1188
1208
  "advanced_cache_enabled": True,
1189
1209
  "cache_v2_enabled": True,
1190
1210
  "cache_initialized": self._metadata_initialized,
1191
- "sync_manager_available": self.sync_manager is not None,
1211
+ "sync_manager_available": self.sync_manager is not None or self._sync_session_manager is not None,
1192
1212
  "background_sync_running": self._is_background_sync_running(),
1193
1213
  "statistics": stats,
1194
1214
  }
@@ -0,0 +1,431 @@
1
+ """Credential source management for D365 F&O client authentication."""
2
+
3
+ import logging
4
+ import os
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timedelta
8
+ from typing import Any, Dict, Optional, Tuple, Union
9
+
10
+ from azure.identity import ClientSecretCredential, DefaultAzureCredential
11
+ from azure.keyvault.secrets import SecretClient
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class CredentialSource:
18
+ """Base credential source configuration."""
19
+
20
+ source_type: str # "environment", "keyvault", "file", etc.
21
+
22
+ def to_dict(self) -> Dict[str, str]:
23
+ """Convert to dictionary for serialization."""
24
+ return {"source_type": self.source_type}
25
+
26
+ @classmethod
27
+ def from_dict(cls, data: Dict[str, Any]) -> "CredentialSource":
28
+ """Create CredentialSource from dictionary."""
29
+ source_type = data.get("source_type")
30
+
31
+ if source_type == "environment":
32
+ return EnvironmentCredentialSource(
33
+ client_id_var=data.get("client_id_var", "D365FO_CLIENT_ID"),
34
+ client_secret_var=data.get("client_secret_var", "D365FO_CLIENT_SECRET"),
35
+ tenant_id_var=data.get("tenant_id_var", "D365FO_TENANT_ID")
36
+ )
37
+ elif source_type == "keyvault":
38
+ return KeyVaultCredentialSource(
39
+ vault_url=data.get("vault_url", ""),
40
+ client_id_secret_name=data.get("client_id_secret_name", "D365FO_CLIENT_ID"),
41
+ client_secret_secret_name=data.get("client_secret_secret_name", "D365FO_CLIENT_SECRET"),
42
+ tenant_id_secret_name=data.get("tenant_id_secret_name", "D365FO_TENANT_ID"),
43
+ keyvault_auth_mode=data.get("keyvault_auth_mode", "default"),
44
+ keyvault_client_id=data.get("keyvault_client_id"),
45
+ keyvault_client_secret=data.get("keyvault_client_secret"),
46
+ keyvault_tenant_id=data.get("keyvault_tenant_id")
47
+ )
48
+ else:
49
+ raise ValueError(f"Unknown credential source type: {source_type}")
50
+
51
+
52
+ @dataclass
53
+ class EnvironmentCredentialSource(CredentialSource):
54
+ """Environment variable credential source."""
55
+
56
+ client_id_var: str = "D365FO_CLIENT_ID"
57
+ client_secret_var: str = "D365FO_CLIENT_SECRET"
58
+ tenant_id_var: str = "D365FO_TENANT_ID"
59
+
60
+ def __init__(self, client_id_var: str = "D365FO_CLIENT_ID",
61
+ client_secret_var: str = "D365FO_CLIENT_SECRET",
62
+ tenant_id_var: str = "D365FO_TENANT_ID"):
63
+ """Initialize environment credential source."""
64
+ super().__init__(source_type="environment")
65
+ self.client_id_var = client_id_var
66
+ self.client_secret_var = client_secret_var
67
+ self.tenant_id_var = tenant_id_var
68
+
69
+ def to_dict(self) -> Dict[str, str]:
70
+ """Convert to dictionary for serialization."""
71
+ return {
72
+ "source_type": self.source_type,
73
+ "client_id_var": self.client_id_var,
74
+ "client_secret_var": self.client_secret_var,
75
+ "tenant_id_var": self.tenant_id_var,
76
+ }
77
+
78
+
79
+ @dataclass
80
+ class KeyVaultCredentialSource(CredentialSource):
81
+ """Azure Key Vault credential source."""
82
+
83
+ vault_url: str
84
+ client_id_secret_name: str
85
+ client_secret_secret_name: str
86
+ tenant_id_secret_name: str
87
+ # Authentication to Key Vault itself
88
+ keyvault_auth_mode: str = "default" # "default" or "client_secret"
89
+ keyvault_client_id: Optional[str] = None
90
+ keyvault_client_secret: Optional[str] = None
91
+ keyvault_tenant_id: Optional[str] = None
92
+
93
+ def __init__(self, vault_url: str, client_id_secret_name: str,
94
+ client_secret_secret_name: str, tenant_id_secret_name: str,
95
+ keyvault_auth_mode: str = "default",
96
+ keyvault_client_id: Optional[str] = None,
97
+ keyvault_client_secret: Optional[str] = None,
98
+ keyvault_tenant_id: Optional[str] = None):
99
+ """Initialize Key Vault credential source."""
100
+ super().__init__(source_type="keyvault")
101
+ self.vault_url = vault_url
102
+ self.client_id_secret_name = client_id_secret_name
103
+ self.client_secret_secret_name = client_secret_secret_name
104
+ self.tenant_id_secret_name = tenant_id_secret_name
105
+ self.keyvault_auth_mode = keyvault_auth_mode
106
+ self.keyvault_client_id = keyvault_client_id
107
+ self.keyvault_client_secret = keyvault_client_secret
108
+ self.keyvault_tenant_id = keyvault_tenant_id
109
+
110
+ def to_dict(self) -> Dict[str, str]:
111
+ """Convert to dictionary for serialization."""
112
+ result = {
113
+ "source_type": self.source_type,
114
+ "vault_url": self.vault_url,
115
+ "client_id_secret_name": self.client_id_secret_name,
116
+ "client_secret_secret_name": self.client_secret_secret_name,
117
+ "tenant_id_secret_name": self.tenant_id_secret_name,
118
+ "keyvault_auth_mode": self.keyvault_auth_mode,
119
+ }
120
+
121
+ # Only include Key Vault auth credentials if using client_secret mode
122
+ if self.keyvault_auth_mode == "client_secret":
123
+ result.update({
124
+ "keyvault_client_id": self.keyvault_client_id or "",
125
+ "keyvault_client_secret": self.keyvault_client_secret or "",
126
+ "keyvault_tenant_id": self.keyvault_tenant_id or "",
127
+ })
128
+
129
+ return result
130
+
131
+
132
+ @dataclass
133
+ class CachedCredentials:
134
+ """Cached credential information with expiry."""
135
+
136
+ client_id: str
137
+ client_secret: str
138
+ tenant_id: str
139
+ expires_at: datetime
140
+ source_hash: str # Hash of source configuration for invalidation
141
+
142
+ def is_expired(self) -> bool:
143
+ """Check if credentials are expired."""
144
+ return datetime.now() >= self.expires_at
145
+
146
+ def is_valid_for_source(self, source_hash: str) -> bool:
147
+ """Check if cached credentials are valid for the given source."""
148
+ return not self.is_expired() and self.source_hash == source_hash
149
+
150
+
151
+ class CredentialProvider(ABC):
152
+ """Base class for credential providers."""
153
+
154
+ @abstractmethod
155
+ async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
156
+ """
157
+ Retrieve credentials from the source.
158
+
159
+ Args:
160
+ source: Credential source configuration
161
+
162
+ Returns:
163
+ Tuple of (client_id, client_secret, tenant_id)
164
+
165
+ Raises:
166
+ ValueError: If credentials cannot be retrieved or are invalid
167
+ Exception: For other retrieval errors
168
+ """
169
+ pass
170
+
171
+ def _get_source_hash(self, source: CredentialSource) -> str:
172
+ """Generate a hash for the credential source configuration."""
173
+ import hashlib
174
+ source_data = str(source.to_dict())
175
+ return hashlib.sha256(source_data.encode()).hexdigest()[:16]
176
+
177
+
178
+ class EnvironmentCredentialProvider(CredentialProvider):
179
+ """Provides credentials from environment variables."""
180
+
181
+ async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
182
+ """
183
+ Retrieve credentials from environment variables.
184
+
185
+ Args:
186
+ source: EnvironmentCredentialSource configuration
187
+
188
+ Returns:
189
+ Tuple of (client_id, client_secret, tenant_id)
190
+
191
+ Raises:
192
+ ValueError: If required environment variables are missing
193
+ """
194
+ if not isinstance(source, EnvironmentCredentialSource):
195
+ raise ValueError(f"Expected EnvironmentCredentialSource, got {type(source)}")
196
+
197
+ client_id = os.getenv(source.client_id_var)
198
+ client_secret = os.getenv(source.client_secret_var)
199
+ tenant_id = os.getenv(source.tenant_id_var)
200
+
201
+ missing_vars = []
202
+ if not client_id:
203
+ missing_vars.append(source.client_id_var)
204
+ if not client_secret:
205
+ missing_vars.append(source.client_secret_var)
206
+ if not tenant_id:
207
+ missing_vars.append(source.tenant_id_var)
208
+
209
+ if missing_vars:
210
+ raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
211
+
212
+ logger.debug(f"Retrieved credentials from environment variables: {source.client_id_var}, {source.client_secret_var}, {source.tenant_id_var}")
213
+ return client_id, client_secret, tenant_id
214
+
215
+
216
+ class KeyVaultCredentialProvider(CredentialProvider):
217
+ """Provides credentials from Azure Key Vault."""
218
+
219
+ def __init__(self):
220
+ """Initialize Key Vault credential provider."""
221
+ self._secret_clients: Dict[str, SecretClient] = {}
222
+
223
+ async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
224
+ """
225
+ Retrieve credentials from Azure Key Vault.
226
+
227
+ Args:
228
+ source: KeyVaultCredentialSource configuration
229
+
230
+ Returns:
231
+ Tuple of (client_id, client_secret, tenant_id)
232
+
233
+ Raises:
234
+ ValueError: If Key Vault access fails or secrets are missing
235
+ """
236
+ if not isinstance(source, KeyVaultCredentialSource):
237
+ raise ValueError(f"Expected KeyVaultCredentialSource, got {type(source)}")
238
+
239
+ try:
240
+ secret_client = self._get_secret_client(source)
241
+
242
+ # Retrieve secrets from Key Vault
243
+ client_id_secret = secret_client.get_secret(source.client_id_secret_name)
244
+ client_secret_secret = secret_client.get_secret(source.client_secret_secret_name)
245
+ tenant_id_secret = secret_client.get_secret(source.tenant_id_secret_name)
246
+
247
+ client_id = client_id_secret.value
248
+ client_secret = client_secret_secret.value
249
+ tenant_id = tenant_id_secret.value
250
+
251
+ if not all([client_id, client_secret, tenant_id]):
252
+ raise ValueError("One or more secrets retrieved from Key Vault are empty")
253
+
254
+ logger.debug(f"Retrieved credentials from Key Vault: {source.vault_url}")
255
+ return client_id, client_secret, tenant_id
256
+
257
+ except Exception as e:
258
+ logger.error(f"Failed to retrieve credentials from Key Vault {source.vault_url}: {e}")
259
+ raise ValueError(f"Key Vault credential retrieval failed: {e}")
260
+
261
+ def _get_secret_client(self, source: KeyVaultCredentialSource) -> SecretClient:
262
+ """
263
+ Get or create a SecretClient for the Key Vault.
264
+
265
+ Args:
266
+ source: KeyVaultCredentialSource configuration
267
+
268
+ Returns:
269
+ SecretClient instance
270
+ """
271
+ vault_url = source.vault_url
272
+
273
+ # Return cached client if available
274
+ if vault_url in self._secret_clients:
275
+ return self._secret_clients[vault_url]
276
+
277
+ # Create credential for Key Vault authentication
278
+ if source.keyvault_auth_mode == "default":
279
+ credential = DefaultAzureCredential()
280
+ logger.debug(f"Using default credentials for Key Vault authentication: {vault_url}")
281
+ elif source.keyvault_auth_mode == "client_secret":
282
+ if not all([source.keyvault_client_id, source.keyvault_client_secret, source.keyvault_tenant_id]):
283
+ raise ValueError("Key Vault client_secret authentication requires keyvault_client_id, keyvault_client_secret, and keyvault_tenant_id")
284
+
285
+ credential = ClientSecretCredential(
286
+ tenant_id=source.keyvault_tenant_id,
287
+ client_id=source.keyvault_client_id,
288
+ client_secret=source.keyvault_client_secret,
289
+ )
290
+ logger.debug(f"Using client secret credentials for Key Vault authentication: {vault_url}")
291
+ else:
292
+ raise ValueError(f"Invalid keyvault_auth_mode: {source.keyvault_auth_mode}. Must be 'default' or 'client_secret'")
293
+
294
+ # Create and cache the SecretClient
295
+ secret_client = SecretClient(vault_url=vault_url, credential=credential)
296
+ self._secret_clients[vault_url] = secret_client
297
+
298
+ return secret_client
299
+
300
+
301
+ class CredentialManager:
302
+ """
303
+ Manages credential retrieval from multiple sources with caching.
304
+
305
+ This class provides a unified interface for retrieving credentials from
306
+ various sources (environment variables, Key Vault, etc.) with automatic
307
+ caching and validation.
308
+ """
309
+
310
+ def __init__(self, cache_ttl_minutes: int = 30):
311
+ """
312
+ Initialize credential manager.
313
+
314
+ Args:
315
+ cache_ttl_minutes: Time-to-live for cached credentials in minutes
316
+ """
317
+ self.cache_ttl_minutes = cache_ttl_minutes
318
+ self._credential_cache: Dict[str, CachedCredentials] = {}
319
+ self._providers: Dict[str, CredentialProvider] = {
320
+ "environment": EnvironmentCredentialProvider(),
321
+ "keyvault": KeyVaultCredentialProvider(),
322
+ }
323
+
324
+ async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
325
+ """
326
+ Retrieve credentials from the specified source with caching.
327
+
328
+ Args:
329
+ source: Credential source configuration
330
+
331
+ Returns:
332
+ Tuple of (client_id, client_secret, tenant_id)
333
+
334
+ Raises:
335
+ ValueError: If source type is unsupported or credentials cannot be retrieved
336
+ """
337
+ # Check cache first
338
+ source_hash = self._get_source_hash(source)
339
+ cache_key = f"{source.source_type}:{source_hash}"
340
+
341
+ if cache_key in self._credential_cache:
342
+ cached = self._credential_cache[cache_key]
343
+ if cached.is_valid_for_source(source_hash):
344
+ logger.debug(f"Using cached credentials for source type: {source.source_type}")
345
+ return cached.client_id, cached.client_secret, cached.tenant_id
346
+ else:
347
+ # Remove expired cache entry
348
+ del self._credential_cache[cache_key]
349
+ logger.debug(f"Expired cached credentials removed for source type: {source.source_type}")
350
+
351
+ # Retrieve from provider
352
+ provider = self._providers.get(source.source_type)
353
+ if not provider:
354
+ raise ValueError(f"Unsupported credential source type: {source.source_type}")
355
+
356
+ client_id, client_secret, tenant_id = await provider.get_credentials(source)
357
+
358
+ # Cache the credentials
359
+ expires_at = datetime.now() + timedelta(minutes=self.cache_ttl_minutes)
360
+ cached_credentials = CachedCredentials(
361
+ client_id=client_id,
362
+ client_secret=client_secret,
363
+ tenant_id=tenant_id,
364
+ expires_at=expires_at,
365
+ source_hash=source_hash,
366
+ )
367
+ self._credential_cache[cache_key] = cached_credentials
368
+
369
+ logger.debug(f"Retrieved and cached credentials from source type: {source.source_type}")
370
+ return client_id, client_secret, tenant_id
371
+
372
+ def _get_source_hash(self, source: CredentialSource) -> str:
373
+ """Generate a hash for the credential source configuration."""
374
+ import hashlib
375
+ source_data = str(source.to_dict())
376
+ return hashlib.sha256(source_data.encode()).hexdigest()[:16]
377
+
378
+ def clear_cache(self, source_type: Optional[str] = None) -> None:
379
+ """
380
+ Clear credential cache.
381
+
382
+ Args:
383
+ source_type: If specified, only clear cache for this source type.
384
+ If None, clear all cached credentials.
385
+ """
386
+ if source_type is None:
387
+ self._credential_cache.clear()
388
+ logger.debug("Cleared all cached credentials")
389
+ else:
390
+ keys_to_remove = [key for key in self._credential_cache.keys() if key.startswith(f"{source_type}:")]
391
+ for key in keys_to_remove:
392
+ del self._credential_cache[key]
393
+ logger.debug(f"Cleared cached credentials for source type: {source_type}")
394
+
395
+ def get_cache_stats(self) -> Dict[str, int]:
396
+ """
397
+ Get cache statistics.
398
+
399
+ Returns:
400
+ Dictionary with cache statistics
401
+ """
402
+ total_cached = len(self._credential_cache)
403
+ expired_count = sum(1 for cached in self._credential_cache.values() if cached.is_expired())
404
+
405
+ return {
406
+ "total_cached": total_cached,
407
+ "expired": expired_count,
408
+ "active": total_cached - expired_count,
409
+ }
410
+
411
+
412
+ def create_credential_source(source_type: str, **kwargs) -> CredentialSource:
413
+ """
414
+ Factory function to create credential source instances.
415
+
416
+ Args:
417
+ source_type: Type of credential source ("environment", "keyvault")
418
+ **kwargs: Source-specific configuration parameters
419
+
420
+ Returns:
421
+ CredentialSource instance
422
+
423
+ Raises:
424
+ ValueError: If source_type is unsupported
425
+ """
426
+ if source_type == "environment":
427
+ return EnvironmentCredentialSource(**kwargs)
428
+ elif source_type == "keyvault":
429
+ return KeyVaultCredentialSource(**kwargs)
430
+ else:
431
+ raise ValueError(f"Unsupported credential source type: {source_type}")
@@ -201,6 +201,14 @@ class D365FOClientManager:
201
201
  raise ValueError(
202
202
  f"Profile '{env_profile.name}' has no base_url configured"
203
203
  )
204
+
205
+ # Check if legacy config should override certain settings
206
+ default_config = self.config.get("default_environment", {})
207
+ if default_config and profile == "default":
208
+ # Override use_default_credentials if specified in legacy config
209
+ if "use_default_credentials" in default_config:
210
+ config.use_default_credentials = default_config["use_default_credentials"]
211
+
204
212
  return config
205
213
 
206
214
  # Fallback to legacy config-based profiles