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 +4 -48
- d365fo_client/auth.py +48 -9
- d365fo_client/client.py +84 -44
- d365fo_client/credential_sources.py +431 -0
- d365fo_client/mcp/client_manager.py +8 -0
- d365fo_client/mcp/main.py +39 -17
- d365fo_client/mcp/models.py +2 -2
- d365fo_client/mcp/server.py +69 -22
- d365fo_client/mcp/tools/__init__.py +2 -0
- d365fo_client/mcp/tools/connection_tools.py +7 -0
- d365fo_client/mcp/tools/profile_tools.py +261 -2
- d365fo_client/mcp/tools/sync_tools.py +503 -0
- d365fo_client/metadata_api.py +68 -1
- d365fo_client/metadata_v2/cache_v2.py +26 -19
- d365fo_client/metadata_v2/database_v2.py +93 -0
- d365fo_client/metadata_v2/global_version_manager.py +62 -4
- d365fo_client/metadata_v2/sync_manager_v2.py +1 -1
- d365fo_client/metadata_v2/sync_session_manager.py +1043 -0
- d365fo_client/models.py +41 -13
- d365fo_client/profile_manager.py +7 -1
- d365fo_client/profiles.py +28 -1
- d365fo_client/sync_models.py +181 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/METADATA +48 -17
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/RECORD +28 -24
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/top_level.txt +0 -0
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
|
-
|
186
|
-
|
187
|
-
|
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
|
-
|
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.
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
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
|
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
|
-
#
|
128
|
-
|
129
|
-
self.
|
130
|
-
|
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
|
-
|
144
|
-
|
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
|
-
|
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
|
-
|
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
|
-
) ->
|
686
|
-
"""Get data entities
|
711
|
+
) -> Dict[str, Any]:
|
712
|
+
"""Get data entities from DataEntities metadata endpoint
|
687
713
|
|
688
714
|
Args:
|
689
|
-
options: OData query options
|
715
|
+
options: OData query options
|
690
716
|
|
691
717
|
Returns:
|
692
|
-
|
718
|
+
Response containing data entities
|
693
719
|
"""
|
694
|
-
|
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
|
-
#
|
1065
|
+
# Always check if we're in an async context first
|
1041
1066
|
try:
|
1042
|
-
loop = asyncio.
|
1043
|
-
|
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
|
-
|
1055
|
-
#
|
1056
|
-
|
1057
|
-
|
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
|
-
"
|
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
|
}
|