d365fo-client 0.2.1__py3-none-any.whl → 0.2.3__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/__init__.py CHANGED
@@ -174,55 +174,13 @@ from .main import main
174
174
  # MCP Server
175
175
  from .mcp import D365FOClientManager, D365FOMCPServer
176
176
 
177
- # Legacy Metadata Cache (deprecated - use metadata_v2)
178
- # REMOVED: Legacy classes have been replaced with V2 implementations
179
- # from .metadata_cache import MetadataCache, MetadataSearchEngine
180
-
181
177
  # V2 Metadata Cache (recommended - now the only implementation)
182
178
  from .metadata_v2 import MetadataCacheV2, VersionAwareSearchEngine
183
179
 
184
180
  # Provide backward compatibility with immediate import errors
185
- import warnings
186
-
187
- class _DeprecatedMetadataCache:
188
- """Deprecated placeholder for MetadataCache - raises error on any access"""
189
- def __init__(self, *args, **kwargs):
190
- warnings.warn(
191
- "MetadataCache is deprecated and has been removed. "
192
- "Use MetadataCacheV2 from d365fo_client.metadata_v2 instead.",
193
- DeprecationWarning,
194
- stacklevel=2
195
- )
196
- raise ImportError(
197
- "MetadataCache has been removed. Use MetadataCacheV2 from d365fo_client.metadata_v2 instead."
198
- )
199
-
200
- def __getattr__(self, name):
201
- raise ImportError(
202
- "MetadataCache has been removed. Use MetadataCacheV2 from d365fo_client.metadata_v2 instead."
203
- )
204
-
205
- class _DeprecatedMetadataSearchEngine:
206
- """Deprecated placeholder for MetadataSearchEngine - raises error on any access"""
207
- def __init__(self, *args, **kwargs):
208
- warnings.warn(
209
- "MetadataSearchEngine is deprecated and has been removed. "
210
- "Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead.",
211
- DeprecationWarning,
212
- stacklevel=2
213
- )
214
- raise ImportError(
215
- "MetadataSearchEngine has been removed. Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead."
216
- )
217
-
218
- def __getattr__(self, name):
219
- raise ImportError(
220
- "MetadataSearchEngine has been removed. Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead."
221
- )
222
-
223
- # Create deprecated placeholder classes
224
- MetadataCache = _DeprecatedMetadataCache
225
- MetadataSearchEngine = _DeprecatedMetadataSearchEngine
181
+
182
+
183
+
226
184
  from .models import (
227
185
  ActionInfo,
228
186
  DataEntityInfo,
@@ -255,9 +213,7 @@ __all__ = [
255
213
  # Main client
256
214
  "FOClient",
257
215
  "create_client",
258
- # Legacy caching (deprecated placeholders - raise errors when used)
259
- "MetadataCache",
260
- "MetadataSearchEngine",
216
+
261
217
  # V2 caching (now the primary implementation)
262
218
  "MetadataCacheV2",
263
219
  "VersionAwareSearchEngine",
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
 
@@ -113,23 +122,27 @@ class FOClient:
113
122
  self.config.enable_metadata_cache = False
114
123
 
115
124
  async def _trigger_background_sync_if_needed(self):
116
- """Trigger background sync if metadata is stale or missing"""
125
+ """Trigger background sync if metadata is stale or missing (non-blocking)"""
117
126
  if not self.config.enable_metadata_cache or not self._metadata_initialized:
118
127
  return
119
128
 
129
+ # Don't trigger sync if already running
130
+ if self._is_background_sync_running():
131
+ return
132
+
120
133
  try:
121
134
  # Check if we need to sync using the new v2 API
135
+ # This should be a quick check, not actual sync work
122
136
  sync_needed, global_version_id = (
123
137
  await self.metadata_cache.check_version_and_sync(self.metadata_api_ops)
124
138
  )
125
139
 
126
140
  if sync_needed and global_version_id:
127
- # Only start sync if not already running
128
- if not self._background_sync_task or self._background_sync_task.done():
129
- self._background_sync_task = asyncio.create_task(
130
- self._background_sync_worker(global_version_id)
131
- )
132
- self.logger.debug("Background metadata sync triggered")
141
+ # Start sync in background without awaiting it
142
+ self._background_sync_task = asyncio.create_task(
143
+ self._background_sync_worker(global_version_id)
144
+ )
145
+ self.logger.debug("Background metadata sync triggered")
133
146
  except Exception as e:
134
147
  self.logger.warning(f"Failed to check sync status: {e}")
135
148
 
@@ -140,18 +153,9 @@ class FOClient:
140
153
  f"Starting background metadata sync for version {global_version_id}"
141
154
  )
142
155
 
143
- # Use self as the fo_client for sync - SmartSyncManagerV2 expects a client with metadata API operations
144
- result = await self.sync_manager.sync_metadata(global_version_id)
145
-
146
- if result.success:
147
- self.logger.info(
148
- f"Background sync completed: "
149
- f"{result.entity_count} entities, "
150
- f"{result.enumeration_count} enumerations, "
151
- f"{result.duration_ms:.2f}ms"
152
- )
153
- else:
154
- 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
+
155
159
 
156
160
  except Exception as e:
157
161
  self.logger.error(f"Background sync error: {e}")
@@ -168,6 +172,27 @@ class FOClient:
168
172
  and not self._background_sync_task.done()
169
173
  )
170
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
+
171
196
  async def _get_from_cache_first(
172
197
  self,
173
198
  cache_method,
@@ -222,7 +247,8 @@ class FOClient:
222
247
 
223
248
  # If cache returns empty result, trigger sync and try fallback
224
249
  if not result or (isinstance(result, list) and len(result) == 0):
225
- await self._trigger_background_sync_if_needed()
250
+ # Trigger background sync without awaiting (fire-and-forget)
251
+ asyncio.create_task(self._trigger_background_sync_if_needed())
226
252
  return (
227
253
  await fallback_method(*args, **kwargs)
228
254
  if asyncio.iscoroutinefunction(fallback_method)
@@ -233,8 +259,8 @@ class FOClient:
233
259
 
234
260
  except Exception as e:
235
261
  self.logger.warning(f"Cache lookup failed, using fallback: {e}")
236
- # Trigger sync if cache failed
237
- await self._trigger_background_sync_if_needed()
262
+ # Trigger sync if cache failed (fire-and-forget)
263
+ asyncio.create_task(self._trigger_background_sync_if_needed())
238
264
  return (
239
265
  await fallback_method(*args, **kwargs)
240
266
  if asyncio.iscoroutinefunction(fallback_method)
@@ -682,17 +708,16 @@ class FOClient:
682
708
 
683
709
  async def get_data_entities(
684
710
  self, options: Optional[QueryOptions] = None
685
- ) -> List[DataEntityInfo]:
686
- """Get data entities - updated to return list for v2 sync compatibility
711
+ ) -> Dict[str, Any]:
712
+ """Get data entities from DataEntities metadata endpoint
687
713
 
688
714
  Args:
689
- options: OData query options (ignored for now)
715
+ options: OData query options
690
716
 
691
717
  Returns:
692
- List of DataEntityInfo objects
718
+ Response containing data entities
693
719
  """
694
- # For sync manager compatibility, return list of DataEntityInfo objects
695
- return await self.metadata_api_ops.search_data_entities("") # Get all entities
720
+ return await self.metadata_api_ops.get_data_entities(options)
696
721
 
697
722
  async def get_data_entities_raw(
698
723
  self, options: Optional[QueryOptions] = None
@@ -929,7 +954,7 @@ class FOClient:
929
954
  Returns:
930
955
  Response containing public enumerations
931
956
  """
932
- self._ensure_metadata_initialized()
957
+ await self._ensure_metadata_initialized()
933
958
 
934
959
  return await self.metadata_api_ops.get_public_enumerations(options)
935
960
 
@@ -1037,29 +1062,44 @@ class FOClient:
1037
1062
  try:
1038
1063
  import asyncio
1039
1064
 
1040
- # Get the current event loop or create a new one
1065
+ # Always check if we're in an async context first
1041
1066
  try:
1042
- loop = asyncio.get_event_loop()
1043
- except RuntimeError:
1044
- loop = asyncio.new_event_loop()
1045
- asyncio.set_event_loop(loop)
1046
-
1047
- if loop.is_running():
1048
- # If we're in an async context, we can't run async code synchronously
1067
+ loop = asyncio.get_running_loop()
1068
+ # We're in an async context, can't run async code synchronously
1049
1069
  return {
1050
1070
  "enabled": True,
1051
1071
  "cache_type": "metadata_v2",
1052
1072
  "message": "Label caching enabled with v2 cache (statistics available via async method)",
1053
1073
  }
1054
- else:
1055
- # If we're not in an async context, we can get the statistics
1056
- stats = loop.run_until_complete(
1057
- self.metadata_cache.get_label_cache_statistics()
1058
- )
1074
+ except RuntimeError:
1075
+ # No running loop, we can safely create one
1076
+ pass
1077
+
1078
+ # Create a new event loop for synchronous execution
1079
+ try:
1080
+ loop = asyncio.new_event_loop()
1081
+ asyncio.set_event_loop(loop)
1082
+ try:
1083
+ stats = loop.run_until_complete(
1084
+ self.metadata_cache.get_label_cache_statistics()
1085
+ )
1086
+ return {
1087
+ "enabled": True,
1088
+ "cache_type": "metadata_v2",
1089
+ "statistics": stats,
1090
+ }
1091
+ finally:
1092
+ loop.close()
1093
+ # Remove the loop to clean up
1094
+ try:
1095
+ asyncio.set_event_loop(None)
1096
+ except:
1097
+ pass
1098
+ except Exception as e:
1059
1099
  return {
1060
1100
  "enabled": True,
1061
1101
  "cache_type": "metadata_v2",
1062
- "statistics": stats,
1102
+ "error": f"Error getting statistics: {e}",
1063
1103
  }
1064
1104
  except Exception as e:
1065
1105
  return {
@@ -1168,7 +1208,7 @@ class FOClient:
1168
1208
  "advanced_cache_enabled": True,
1169
1209
  "cache_v2_enabled": True,
1170
1210
  "cache_initialized": self._metadata_initialized,
1171
- "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,
1172
1212
  "background_sync_running": self._is_background_sync_running(),
1173
1213
  "statistics": stats,
1174
1214
  }