d365fo-client 0.1.0__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 +305 -0
- d365fo_client/auth.py +93 -0
- d365fo_client/cli.py +700 -0
- d365fo_client/client.py +1454 -0
- d365fo_client/config.py +304 -0
- d365fo_client/crud.py +200 -0
- d365fo_client/exceptions.py +49 -0
- d365fo_client/labels.py +528 -0
- d365fo_client/main.py +502 -0
- d365fo_client/mcp/__init__.py +16 -0
- d365fo_client/mcp/client_manager.py +276 -0
- d365fo_client/mcp/main.py +98 -0
- d365fo_client/mcp/models.py +371 -0
- d365fo_client/mcp/prompts/__init__.py +43 -0
- d365fo_client/mcp/prompts/action_execution.py +480 -0
- d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
- d365fo_client/mcp/resources/__init__.py +15 -0
- d365fo_client/mcp/resources/database_handler.py +555 -0
- d365fo_client/mcp/resources/entity_handler.py +176 -0
- d365fo_client/mcp/resources/environment_handler.py +132 -0
- d365fo_client/mcp/resources/metadata_handler.py +283 -0
- d365fo_client/mcp/resources/query_handler.py +135 -0
- d365fo_client/mcp/server.py +432 -0
- d365fo_client/mcp/tools/__init__.py +17 -0
- d365fo_client/mcp/tools/connection_tools.py +175 -0
- d365fo_client/mcp/tools/crud_tools.py +579 -0
- d365fo_client/mcp/tools/database_tools.py +813 -0
- d365fo_client/mcp/tools/label_tools.py +189 -0
- d365fo_client/mcp/tools/metadata_tools.py +766 -0
- d365fo_client/mcp/tools/profile_tools.py +706 -0
- d365fo_client/metadata_api.py +793 -0
- d365fo_client/metadata_v2/__init__.py +59 -0
- d365fo_client/metadata_v2/cache_v2.py +1372 -0
- d365fo_client/metadata_v2/database_v2.py +585 -0
- d365fo_client/metadata_v2/global_version_manager.py +573 -0
- d365fo_client/metadata_v2/search_engine_v2.py +423 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
- d365fo_client/metadata_v2/version_detector.py +439 -0
- d365fo_client/models.py +862 -0
- d365fo_client/output.py +181 -0
- d365fo_client/profile_manager.py +342 -0
- d365fo_client/profiles.py +178 -0
- d365fo_client/query.py +162 -0
- d365fo_client/session.py +60 -0
- d365fo_client/utils.py +196 -0
- d365fo_client-0.1.0.dist-info/METADATA +1084 -0
- d365fo_client-0.1.0.dist-info/RECORD +51 -0
- d365fo_client-0.1.0.dist-info/WHEEL +5 -0
- d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
- d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
d365fo_client/client.py
ADDED
@@ -0,0 +1,1454 @@
|
|
1
|
+
"""Main F&O client implementation."""
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import logging
|
5
|
+
import os
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import Any, Dict, List, Optional, Union
|
8
|
+
|
9
|
+
from .auth import AuthenticationManager
|
10
|
+
from .crud import CrudOperations
|
11
|
+
from .exceptions import FOClientError
|
12
|
+
from .labels import LabelOperations, resolve_labels_generic
|
13
|
+
from .metadata_api import MetadataAPIOperations
|
14
|
+
from .metadata_v2 import MetadataCacheV2, SmartSyncManagerV2
|
15
|
+
from .models import (
|
16
|
+
ActionInfo,
|
17
|
+
DataEntityInfo,
|
18
|
+
EnumerationInfo,
|
19
|
+
FOClientConfig,
|
20
|
+
PublicEntityInfo,
|
21
|
+
QueryOptions,
|
22
|
+
)
|
23
|
+
from .query import QueryBuilder
|
24
|
+
from .session import SessionManager
|
25
|
+
|
26
|
+
|
27
|
+
class FOClient:
|
28
|
+
"""Main F&O OData Client
|
29
|
+
|
30
|
+
A comprehensive client for connecting to D365 F&O and performing:
|
31
|
+
- Metadata download, storage, and search
|
32
|
+
- OData action method calls
|
33
|
+
- CRUD operations on data entities
|
34
|
+
- OData query parameters support
|
35
|
+
- Label text retrieval and caching
|
36
|
+
- Multilingual label support
|
37
|
+
- Entity metadata with resolved labels
|
38
|
+
"""
|
39
|
+
|
40
|
+
def __init__(self, config: Union[FOClientConfig, str, Dict[str, Any]]):
|
41
|
+
"""Initialize F&O client
|
42
|
+
|
43
|
+
Args:
|
44
|
+
config: FOClientConfig object, base_url string, or config dict
|
45
|
+
"""
|
46
|
+
# Convert config to FOClientConfig if needed
|
47
|
+
if isinstance(config, str):
|
48
|
+
config = FOClientConfig(base_url=config)
|
49
|
+
elif isinstance(config, dict):
|
50
|
+
config = FOClientConfig(**config)
|
51
|
+
|
52
|
+
self.config = config
|
53
|
+
self.logger = logging.getLogger(__name__)
|
54
|
+
|
55
|
+
# Initialize components
|
56
|
+
self.auth_manager = AuthenticationManager(config)
|
57
|
+
self.session_manager = SessionManager(config, self.auth_manager)
|
58
|
+
|
59
|
+
# Initialize new metadata cache and sync components
|
60
|
+
self.metadata_cache = None
|
61
|
+
self.sync_manager = None
|
62
|
+
self._metadata_initialized = False
|
63
|
+
self._background_sync_task = None
|
64
|
+
|
65
|
+
# Initialize operations
|
66
|
+
self.metadata_url = f"{config.base_url.rstrip('/')}/Metadata"
|
67
|
+
self.crud_ops = CrudOperations(self.session_manager, config.base_url)
|
68
|
+
|
69
|
+
# Initialize label operations - will be updated when metadata cache v2 is initialized
|
70
|
+
self.label_ops = LabelOperations(self.session_manager, self.metadata_url, None)
|
71
|
+
self.metadata_api_ops = MetadataAPIOperations(
|
72
|
+
self.session_manager, self.metadata_url, self.label_ops
|
73
|
+
)
|
74
|
+
|
75
|
+
async def close(self):
|
76
|
+
"""Close the client session"""
|
77
|
+
# Cancel background sync task if running
|
78
|
+
if self._background_sync_task and not self._background_sync_task.done():
|
79
|
+
self._background_sync_task.cancel()
|
80
|
+
try:
|
81
|
+
await self._background_sync_task
|
82
|
+
except asyncio.CancelledError:
|
83
|
+
pass
|
84
|
+
|
85
|
+
await self.session_manager.close()
|
86
|
+
|
87
|
+
async def _ensure_metadata_initialized(self):
|
88
|
+
"""Ensure metadata cache and sync manager are initialized"""
|
89
|
+
if not self._metadata_initialized and self.config.enable_metadata_cache:
|
90
|
+
try:
|
91
|
+
|
92
|
+
cache_dir = Path(self.config.metadata_cache_dir)
|
93
|
+
|
94
|
+
# Initialize metadata cache v2
|
95
|
+
self.metadata_cache = MetadataCacheV2(
|
96
|
+
cache_dir, self.config.base_url, self.metadata_api_ops
|
97
|
+
)
|
98
|
+
# Initialize label operations v2 with cache support
|
99
|
+
self.label_ops.set_label_cache(self.metadata_cache)
|
100
|
+
|
101
|
+
await self.metadata_cache.initialize()
|
102
|
+
|
103
|
+
# Initialize sync manager v2
|
104
|
+
self.sync_manager = SmartSyncManagerV2(
|
105
|
+
self.metadata_cache, self.metadata_api_ops
|
106
|
+
)
|
107
|
+
|
108
|
+
self._metadata_initialized = True
|
109
|
+
self.logger.debug("Metadata cache v2 with label caching initialized")
|
110
|
+
|
111
|
+
except Exception as e:
|
112
|
+
self.logger.warning(f"Failed to initialize metadata cache v2: {e}")
|
113
|
+
# Continue without metadata cache
|
114
|
+
self.config.enable_metadata_cache = False
|
115
|
+
|
116
|
+
async def _trigger_background_sync_if_needed(self):
|
117
|
+
"""Trigger background sync if metadata is stale or missing"""
|
118
|
+
if not self.config.enable_metadata_cache or not self._metadata_initialized:
|
119
|
+
return
|
120
|
+
|
121
|
+
try:
|
122
|
+
# Check if we need to sync using the new v2 API
|
123
|
+
sync_needed, global_version_id = (
|
124
|
+
await self.metadata_cache.check_version_and_sync(self.metadata_api_ops)
|
125
|
+
)
|
126
|
+
|
127
|
+
if sync_needed and global_version_id:
|
128
|
+
# Only start sync if not already running
|
129
|
+
if not self._background_sync_task or self._background_sync_task.done():
|
130
|
+
self._background_sync_task = asyncio.create_task(
|
131
|
+
self._background_sync_worker(global_version_id)
|
132
|
+
)
|
133
|
+
self.logger.debug("Background metadata sync triggered")
|
134
|
+
except Exception as e:
|
135
|
+
self.logger.warning(f"Failed to check sync status: {e}")
|
136
|
+
|
137
|
+
async def _background_sync_worker(self, global_version_id: int):
|
138
|
+
"""Background worker for metadata synchronization"""
|
139
|
+
try:
|
140
|
+
self.logger.info(
|
141
|
+
f"Starting background metadata sync for version {global_version_id}"
|
142
|
+
)
|
143
|
+
|
144
|
+
# Use self as the fo_client for sync - SmartSyncManagerV2 expects a client with metadata API operations
|
145
|
+
result = await self.sync_manager.sync_metadata(global_version_id)
|
146
|
+
|
147
|
+
if result.success:
|
148
|
+
self.logger.info(
|
149
|
+
f"Background sync completed: "
|
150
|
+
f"{result.entity_count} entities, "
|
151
|
+
f"{result.enumeration_count} enumerations, "
|
152
|
+
f"{result.duration_ms:.2f}ms"
|
153
|
+
)
|
154
|
+
else:
|
155
|
+
self.logger.warning(f"Background sync failed: {result.error}")
|
156
|
+
|
157
|
+
except Exception as e:
|
158
|
+
self.logger.error(f"Background sync error: {e}")
|
159
|
+
# Don't re-raise to avoid breaking the background task
|
160
|
+
|
161
|
+
async def _get_from_cache_first(
|
162
|
+
self,
|
163
|
+
cache_method,
|
164
|
+
fallback_method,
|
165
|
+
*args,
|
166
|
+
use_cache_first: Optional[bool] = None,
|
167
|
+
**kwargs,
|
168
|
+
):
|
169
|
+
"""Helper method to implement cache-first pattern
|
170
|
+
|
171
|
+
Args:
|
172
|
+
cache_method: Method to call on cache
|
173
|
+
fallback_method: Method to call as fallback
|
174
|
+
use_cache_first: Override config setting
|
175
|
+
*args: Arguments to pass to methods
|
176
|
+
**kwargs: Keyword arguments to pass to methods
|
177
|
+
"""
|
178
|
+
# Use provided parameter or config default
|
179
|
+
if use_cache_first is None:
|
180
|
+
use_cache_first = self.config.use_cache_first
|
181
|
+
|
182
|
+
# If cache-first is disabled, go straight to fallback
|
183
|
+
if not use_cache_first or not self.config.enable_metadata_cache:
|
184
|
+
return (
|
185
|
+
await fallback_method(*args, **kwargs)
|
186
|
+
if asyncio.iscoroutinefunction(fallback_method)
|
187
|
+
else fallback_method(*args, **kwargs)
|
188
|
+
)
|
189
|
+
|
190
|
+
# Ensure metadata is initialized
|
191
|
+
await self._ensure_metadata_initialized()
|
192
|
+
|
193
|
+
if not self._metadata_initialized:
|
194
|
+
# Cache not available, use fallback
|
195
|
+
return (
|
196
|
+
await fallback_method(*args, **kwargs)
|
197
|
+
if asyncio.iscoroutinefunction(fallback_method)
|
198
|
+
else fallback_method(*args, **kwargs)
|
199
|
+
)
|
200
|
+
|
201
|
+
try:
|
202
|
+
# Try cache first
|
203
|
+
result = (
|
204
|
+
await cache_method(*args, **kwargs)
|
205
|
+
if asyncio.iscoroutinefunction(cache_method)
|
206
|
+
else cache_method(*args, **kwargs)
|
207
|
+
)
|
208
|
+
|
209
|
+
# If cache returns empty result, trigger sync and try fallback
|
210
|
+
if not result or (isinstance(result, list) and len(result) == 0):
|
211
|
+
await self._trigger_background_sync_if_needed()
|
212
|
+
return (
|
213
|
+
await fallback_method(*args, **kwargs)
|
214
|
+
if asyncio.iscoroutinefunction(fallback_method)
|
215
|
+
else fallback_method(*args, **kwargs)
|
216
|
+
)
|
217
|
+
|
218
|
+
return result
|
219
|
+
|
220
|
+
except Exception as e:
|
221
|
+
self.logger.warning(f"Cache lookup failed, using fallback: {e}")
|
222
|
+
# Trigger sync if cache failed
|
223
|
+
await self._trigger_background_sync_if_needed()
|
224
|
+
return (
|
225
|
+
await fallback_method(*args, **kwargs)
|
226
|
+
if asyncio.iscoroutinefunction(fallback_method)
|
227
|
+
else fallback_method(*args, **kwargs)
|
228
|
+
)
|
229
|
+
|
230
|
+
async def __aenter__(self):
|
231
|
+
"""Async context manager entry"""
|
232
|
+
return self
|
233
|
+
|
234
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
235
|
+
"""Async context manager exit"""
|
236
|
+
await self.close()
|
237
|
+
|
238
|
+
# Connection and Testing Methods
|
239
|
+
|
240
|
+
async def test_connection(self) -> bool:
|
241
|
+
"""Test connection to F&O
|
242
|
+
|
243
|
+
Returns:
|
244
|
+
True if connection is successful
|
245
|
+
"""
|
246
|
+
try:
|
247
|
+
session = await self.session_manager.get_session()
|
248
|
+
url = f"{self.config.base_url}/data"
|
249
|
+
|
250
|
+
async with session.get(url) as response:
|
251
|
+
return response.status == 200
|
252
|
+
except Exception as e:
|
253
|
+
print(f"Connection test failed: {e}")
|
254
|
+
return False
|
255
|
+
|
256
|
+
async def test_metadata_connection(self) -> bool:
|
257
|
+
"""Test connection to the Metadata endpoint
|
258
|
+
|
259
|
+
Returns:
|
260
|
+
True if metadata endpoint is accessible
|
261
|
+
"""
|
262
|
+
try:
|
263
|
+
session = await self.session_manager.get_session()
|
264
|
+
|
265
|
+
# Try the PublicEntities endpoint first as it's more reliable
|
266
|
+
url = f"{self.metadata_url}/PublicEntities"
|
267
|
+
params = {"$top": 1}
|
268
|
+
|
269
|
+
async with session.get(url, params=params) as response:
|
270
|
+
if response.status == 200:
|
271
|
+
return True
|
272
|
+
|
273
|
+
except Exception as e:
|
274
|
+
print(f"Metadata connection test failed: {e}")
|
275
|
+
return False
|
276
|
+
|
277
|
+
# Metadata Operations
|
278
|
+
|
279
|
+
async def download_metadata(self, force_refresh: bool = False) -> bool:
|
280
|
+
"""Download/sync metadata using new sync manager v2
|
281
|
+
|
282
|
+
Args:
|
283
|
+
force_refresh: Force full synchronization even if cache is fresh
|
284
|
+
|
285
|
+
Returns:
|
286
|
+
True if successful
|
287
|
+
"""
|
288
|
+
# Ensure metadata components are initialized
|
289
|
+
await self._ensure_metadata_initialized()
|
290
|
+
|
291
|
+
if not self._metadata_initialized:
|
292
|
+
self.logger.error("Metadata cache v2 could not be initialized")
|
293
|
+
return False
|
294
|
+
|
295
|
+
try:
|
296
|
+
self.logger.info("Starting metadata synchronization")
|
297
|
+
|
298
|
+
# Check version and determine if sync is needed
|
299
|
+
sync_needed, global_version_id = (
|
300
|
+
await self.metadata_cache.check_version_and_sync(self.metadata_api_ops)
|
301
|
+
)
|
302
|
+
|
303
|
+
if not sync_needed and not force_refresh:
|
304
|
+
self.logger.info("Metadata is up-to-date, no sync needed")
|
305
|
+
return True
|
306
|
+
|
307
|
+
if not global_version_id:
|
308
|
+
self.logger.error("Could not determine environment version")
|
309
|
+
return False
|
310
|
+
|
311
|
+
# Perform sync using the new sync manager
|
312
|
+
from .models import SyncStrategy
|
313
|
+
|
314
|
+
strategy = SyncStrategy.FULL if force_refresh else SyncStrategy.INCREMENTAL
|
315
|
+
|
316
|
+
result = await self.sync_manager.sync_metadata(global_version_id, strategy)
|
317
|
+
|
318
|
+
if result.success:
|
319
|
+
self.logger.info(
|
320
|
+
f"Metadata sync completed: "
|
321
|
+
f"{result.entity_count} entities, "
|
322
|
+
f"{result.enumeration_count} enumerations, "
|
323
|
+
f"{result.action_count} actions, "
|
324
|
+
f"{result.duration_ms:.2f}ms"
|
325
|
+
)
|
326
|
+
return True
|
327
|
+
else:
|
328
|
+
self.logger.error(f"Metadata sync failed: {result.error}")
|
329
|
+
return False
|
330
|
+
|
331
|
+
except Exception as e:
|
332
|
+
self.logger.error(f"Error during metadata sync: {e}")
|
333
|
+
return False
|
334
|
+
|
335
|
+
async def search_entities(
|
336
|
+
self, pattern: str = "", use_cache_first: Optional[bool] = True
|
337
|
+
) -> List[DataEntityInfo]:
|
338
|
+
"""Search entities by name pattern with cache-first approach
|
339
|
+
|
340
|
+
Args:
|
341
|
+
pattern: Search pattern (regex supported)
|
342
|
+
use_cache_first: Override config setting for cache-first behavior
|
343
|
+
|
344
|
+
Returns:
|
345
|
+
List of matching entity names
|
346
|
+
"""
|
347
|
+
|
348
|
+
async def cache_search():
|
349
|
+
if self.metadata_cache:
|
350
|
+
# Convert regex pattern to SQL LIKE pattern for v2 cache
|
351
|
+
if pattern:
|
352
|
+
# Simple conversion: replace * with % for SQL LIKE
|
353
|
+
sql_pattern = (
|
354
|
+
pattern.replace("*", "%")
|
355
|
+
.replace("?", "")
|
356
|
+
.replace(".", "")
|
357
|
+
.replace("[", "")
|
358
|
+
.replace("]", "")
|
359
|
+
)
|
360
|
+
# If no wildcards, add % at both ends for substring search
|
361
|
+
if "%" not in sql_pattern and "_" not in sql_pattern:
|
362
|
+
sql_pattern = f"%{sql_pattern}%"
|
363
|
+
else:
|
364
|
+
sql_pattern = None
|
365
|
+
|
366
|
+
return await self.metadata_cache.get_data_entities(
|
367
|
+
name_pattern=sql_pattern
|
368
|
+
)
|
369
|
+
return []
|
370
|
+
|
371
|
+
async def fallback_search():
|
372
|
+
# Use metadata API operations as fallback
|
373
|
+
return await self.metadata_api_ops.search_data_entities(pattern)
|
374
|
+
|
375
|
+
return await self._get_from_cache_first(
|
376
|
+
cache_search,
|
377
|
+
fallback_search,
|
378
|
+
use_cache_first=use_cache_first or self.config.use_cache_first,
|
379
|
+
)
|
380
|
+
|
381
|
+
async def get_entity_info(
|
382
|
+
self, entity_name: str, use_cache_first: Optional[bool] = True
|
383
|
+
) -> Optional[PublicEntityInfo]:
|
384
|
+
"""Get detailed entity information with cache-first approach
|
385
|
+
|
386
|
+
Args:
|
387
|
+
entity_name: Name of the entity
|
388
|
+
use_cache_first: Override config setting for cache-first behavior
|
389
|
+
|
390
|
+
Returns:
|
391
|
+
PublicEntityInfo object or None if not found
|
392
|
+
"""
|
393
|
+
return await self.get_public_entity_info(
|
394
|
+
entity_name, use_cache_first=use_cache_first
|
395
|
+
)
|
396
|
+
|
397
|
+
async def search_actions(
|
398
|
+
self,
|
399
|
+
pattern: str = "",
|
400
|
+
entity_name: Optional[str] = None,
|
401
|
+
binding_kind: Optional[str] = None,
|
402
|
+
use_cache_first: Optional[bool] = True,
|
403
|
+
) -> List[ActionInfo]:
|
404
|
+
"""Search actions by name pattern and/or entity with cache-first approach
|
405
|
+
|
406
|
+
Args:
|
407
|
+
pattern: Search pattern for action name (regex supported)
|
408
|
+
entity_name: Filter actions that are bound to a specific entity
|
409
|
+
binding_kind: Filter by binding type (Unbound, BoundToEntitySet, BoundToEntityInstance)
|
410
|
+
use_cache_first: Override config setting for cache-first behavior
|
411
|
+
|
412
|
+
Returns:
|
413
|
+
List of matching ActionInfo objects with full details
|
414
|
+
"""
|
415
|
+
await self._ensure_metadata_initialized()
|
416
|
+
|
417
|
+
async def cache_search():
|
418
|
+
# TODO: v2 cache doesn't have action search yet - will be implemented in future phase
|
419
|
+
# For now, always return empty to force fallback to API
|
420
|
+
return []
|
421
|
+
|
422
|
+
async def fallback_search():
|
423
|
+
# Actions are not directly available through metadata API
|
424
|
+
# They are part of entity definitions
|
425
|
+
# Return empty list for backward compatibility
|
426
|
+
return []
|
427
|
+
|
428
|
+
actions = await self._get_from_cache_first(
|
429
|
+
cache_search,
|
430
|
+
fallback_search,
|
431
|
+
use_cache_first=use_cache_first or self.config.use_cache_first,
|
432
|
+
)
|
433
|
+
|
434
|
+
return await resolve_labels_generic(actions, self.label_ops)
|
435
|
+
|
436
|
+
async def get_action_info(
|
437
|
+
self,
|
438
|
+
action_name: str,
|
439
|
+
entity_name: Optional[str] = None,
|
440
|
+
use_cache_first: Optional[bool] = None,
|
441
|
+
) -> Optional[ActionInfo]:
|
442
|
+
"""Get detailed action information with cache-first approach
|
443
|
+
|
444
|
+
Args:
|
445
|
+
action_name: Name of the action
|
446
|
+
entity_name: Optional entity name for bound actions
|
447
|
+
use_cache_first: Override config setting for cache-first behavior
|
448
|
+
|
449
|
+
Returns:
|
450
|
+
ActionInfo object or None if not found
|
451
|
+
"""
|
452
|
+
|
453
|
+
async def cache_lookup():
|
454
|
+
# TODO: v2 cache doesn't have action lookup yet - will be implemented in future phase
|
455
|
+
# For now, always return None to force fallback to API
|
456
|
+
return None
|
457
|
+
|
458
|
+
async def fallback_lookup():
|
459
|
+
# Actions are not directly available through metadata API
|
460
|
+
# They are part of entity definitions
|
461
|
+
# Return None for backward compatibility
|
462
|
+
return None
|
463
|
+
|
464
|
+
return await self._get_from_cache_first(
|
465
|
+
cache_lookup, fallback_lookup, use_cache_first=use_cache_first
|
466
|
+
)
|
467
|
+
|
468
|
+
# CRUD Operations
|
469
|
+
|
470
|
+
async def get_entities(
|
471
|
+
self, entity_name: str, options: Optional[QueryOptions] = None
|
472
|
+
) -> Dict[str, Any]:
|
473
|
+
"""Get entities with OData query options
|
474
|
+
|
475
|
+
Args:
|
476
|
+
entity_name: Name of the entity set
|
477
|
+
options: OData query options
|
478
|
+
|
479
|
+
Returns:
|
480
|
+
Response containing entities
|
481
|
+
"""
|
482
|
+
return await self.crud_ops.get_entities(entity_name, options)
|
483
|
+
|
484
|
+
async def get_entity(
|
485
|
+
self,
|
486
|
+
entity_name: str,
|
487
|
+
key: Union[str, Dict[str, Any]],
|
488
|
+
options: Optional[QueryOptions] = None,
|
489
|
+
) -> Dict[str, Any]:
|
490
|
+
"""Get single entity by key
|
491
|
+
|
492
|
+
Args:
|
493
|
+
entity_name: Name of the entity set
|
494
|
+
key: Entity key value (string for simple keys, dict for composite keys)
|
495
|
+
options: OData query options
|
496
|
+
|
497
|
+
Returns:
|
498
|
+
Entity data
|
499
|
+
"""
|
500
|
+
return await self.crud_ops.get_entity(entity_name, key, options)
|
501
|
+
|
502
|
+
async def get_entity_by_key(
|
503
|
+
self,
|
504
|
+
entity_name: str,
|
505
|
+
key: Union[str, Dict[str, Any]],
|
506
|
+
select: Optional[List[str]] = None,
|
507
|
+
expand: Optional[List[str]] = None,
|
508
|
+
) -> Optional[Dict[str, Any]]:
|
509
|
+
"""Get single entity by key with optional field selection and expansion
|
510
|
+
|
511
|
+
Args:
|
512
|
+
entity_name: Name of the entity set
|
513
|
+
key: Entity key value (string for simple keys, dict for composite keys)
|
514
|
+
select: Optional list of fields to select
|
515
|
+
expand: Optional list of navigation properties to expand
|
516
|
+
|
517
|
+
Returns:
|
518
|
+
Entity data or None if not found
|
519
|
+
"""
|
520
|
+
try:
|
521
|
+
options = (
|
522
|
+
QueryOptions(select=select, expand=expand) if select or expand else None
|
523
|
+
)
|
524
|
+
return await self.crud_ops.get_entity(entity_name, key, options)
|
525
|
+
except Exception as e:
|
526
|
+
# If the entity is not found, return None instead of raising exception
|
527
|
+
if "404" in str(e):
|
528
|
+
return None
|
529
|
+
raise
|
530
|
+
|
531
|
+
async def create_entity(
|
532
|
+
self, entity_name: str, data: Dict[str, Any]
|
533
|
+
) -> Dict[str, Any]:
|
534
|
+
"""Create new entity
|
535
|
+
|
536
|
+
Args:
|
537
|
+
entity_name: Name of the entity set
|
538
|
+
data: Entity data to create
|
539
|
+
|
540
|
+
Returns:
|
541
|
+
Created entity data
|
542
|
+
"""
|
543
|
+
return await self.crud_ops.create_entity(entity_name, data)
|
544
|
+
|
545
|
+
async def update_entity(
|
546
|
+
self,
|
547
|
+
entity_name: str,
|
548
|
+
key: Union[str, Dict[str, Any]],
|
549
|
+
data: Dict[str, Any],
|
550
|
+
method: str = "PATCH",
|
551
|
+
) -> Dict[str, Any]:
|
552
|
+
"""Update existing entity
|
553
|
+
|
554
|
+
Args:
|
555
|
+
entity_name: Name of the entity set
|
556
|
+
key: Entity key value (string for simple keys, dict for composite keys)
|
557
|
+
data: Updated entity data
|
558
|
+
method: HTTP method (PATCH or PUT)
|
559
|
+
|
560
|
+
Returns:
|
561
|
+
Updated entity data
|
562
|
+
"""
|
563
|
+
return await self.crud_ops.update_entity(entity_name, key, data, method)
|
564
|
+
|
565
|
+
async def delete_entity(
|
566
|
+
self, entity_name: str, key: Union[str, Dict[str, Any]]
|
567
|
+
) -> bool:
|
568
|
+
"""Delete entity
|
569
|
+
|
570
|
+
Args:
|
571
|
+
entity_name: Name of the entity set
|
572
|
+
key: Entity key value (string for simple keys, dict for composite keys)
|
573
|
+
|
574
|
+
Returns:
|
575
|
+
True if successful
|
576
|
+
"""
|
577
|
+
return await self.crud_ops.delete_entity(entity_name, key)
|
578
|
+
|
579
|
+
async def call_action(
|
580
|
+
self,
|
581
|
+
action_name: str,
|
582
|
+
parameters: Optional[Dict[str, Any]] = None,
|
583
|
+
entity_name: Optional[str] = None,
|
584
|
+
entity_key: Optional[Union[str, Dict[str, Any]]] = None,
|
585
|
+
) -> Any:
|
586
|
+
"""Call OData action method
|
587
|
+
|
588
|
+
Args:
|
589
|
+
action_name: Name of the action
|
590
|
+
parameters: Action parameters
|
591
|
+
entity_name: Entity name for bound actions
|
592
|
+
entity_key: Entity key for bound actions (string for simple keys, dict for composite keys)
|
593
|
+
|
594
|
+
Returns:
|
595
|
+
Action result
|
596
|
+
"""
|
597
|
+
return await self.crud_ops.call_action(
|
598
|
+
action_name, parameters, entity_name, entity_key
|
599
|
+
)
|
600
|
+
|
601
|
+
# Label Operations
|
602
|
+
|
603
|
+
async def get_label_text(
|
604
|
+
self, label_id: str, language: str = "en-US"
|
605
|
+
) -> Optional[str]:
|
606
|
+
"""Get actual label text for a specific label ID
|
607
|
+
|
608
|
+
Args:
|
609
|
+
label_id: Label ID (e.g., "@SYS13342")
|
610
|
+
language: Language code (e.g., "en-US")
|
611
|
+
|
612
|
+
Returns:
|
613
|
+
Label text or None if not found
|
614
|
+
"""
|
615
|
+
return await self.label_ops.get_label_text(label_id, language)
|
616
|
+
|
617
|
+
async def get_labels_batch(
|
618
|
+
self, label_ids: List[str], language: str = "en-US"
|
619
|
+
) -> Dict[str, str]:
|
620
|
+
"""Get multiple labels in a single request
|
621
|
+
|
622
|
+
Args:
|
623
|
+
label_ids: List of label IDs
|
624
|
+
language: Language code
|
625
|
+
|
626
|
+
Returns:
|
627
|
+
Dictionary mapping label ID to label text
|
628
|
+
"""
|
629
|
+
return await self.label_ops.get_labels_batch(label_ids, language)
|
630
|
+
|
631
|
+
# Enhanced Entity Operations with Labels
|
632
|
+
|
633
|
+
async def get_entity_info_with_labels(
|
634
|
+
self, entity_name: str, language: str = "en-US"
|
635
|
+
) -> Optional[PublicEntityInfo]:
|
636
|
+
"""Get entity metadata with resolved label text from Metadata API
|
637
|
+
|
638
|
+
Args:
|
639
|
+
entity_name: Name of the entity
|
640
|
+
language: Language code for label resolution
|
641
|
+
|
642
|
+
Returns:
|
643
|
+
PublicEntityInfo object with resolved labels
|
644
|
+
"""
|
645
|
+
# Use the existing get_public_entity_info method which already handles labels
|
646
|
+
return await self.get_public_entity_info(entity_name, language=language)
|
647
|
+
|
648
|
+
# Metadata API Operations
|
649
|
+
|
650
|
+
async def get_data_entities(
|
651
|
+
self, options: Optional[QueryOptions] = None
|
652
|
+
) -> List[DataEntityInfo]:
|
653
|
+
"""Get data entities - updated to return list for v2 sync compatibility
|
654
|
+
|
655
|
+
Args:
|
656
|
+
options: OData query options (ignored for now)
|
657
|
+
|
658
|
+
Returns:
|
659
|
+
List of DataEntityInfo objects
|
660
|
+
"""
|
661
|
+
# For sync manager compatibility, return list of DataEntityInfo objects
|
662
|
+
return await self.metadata_api_ops.search_data_entities("") # Get all entities
|
663
|
+
|
664
|
+
async def get_data_entities_raw(
|
665
|
+
self, options: Optional[QueryOptions] = None
|
666
|
+
) -> Dict[str, Any]:
|
667
|
+
"""Get data entities raw response from DataEntities metadata endpoint
|
668
|
+
|
669
|
+
Args:
|
670
|
+
options: OData query options
|
671
|
+
|
672
|
+
Returns:
|
673
|
+
Response containing data entities
|
674
|
+
"""
|
675
|
+
return await self.metadata_api_ops.get_data_entities(options)
|
676
|
+
|
677
|
+
async def get_data_entities_list(self) -> List[DataEntityInfo]:
|
678
|
+
"""Get data entities as list - compatibility method for SmartSyncManagerV2
|
679
|
+
|
680
|
+
Returns:
|
681
|
+
List of DataEntityInfo objects
|
682
|
+
"""
|
683
|
+
return await self.metadata_api_ops.search_data_entities("") # Get all entities
|
684
|
+
|
685
|
+
async def search_data_entities(
|
686
|
+
self,
|
687
|
+
pattern: str = "",
|
688
|
+
entity_category: Optional[str] = None,
|
689
|
+
data_service_enabled: Optional[bool] = None,
|
690
|
+
data_management_enabled: Optional[bool] = None,
|
691
|
+
is_read_only: Optional[bool] = None,
|
692
|
+
use_cache_first: Optional[bool] = True,
|
693
|
+
) -> List[DataEntityInfo]:
|
694
|
+
"""Search data entities with filtering and cache-first approach
|
695
|
+
|
696
|
+
Args:
|
697
|
+
pattern: Search pattern for entity name (regex supported)
|
698
|
+
entity_category: Filter by entity category (e.g., 'Master', 'Transaction')
|
699
|
+
data_service_enabled: Filter by data service enabled status
|
700
|
+
data_management_enabled: Filter by data management enabled status
|
701
|
+
is_read_only: Filter by read-only status
|
702
|
+
use_cache_first: Override config setting for cache-first behavior
|
703
|
+
|
704
|
+
Returns:
|
705
|
+
List of matching data entities
|
706
|
+
"""
|
707
|
+
|
708
|
+
async def cache_search():
|
709
|
+
if self.metadata_cache:
|
710
|
+
# Convert regex pattern to SQL LIKE pattern for v2 cache
|
711
|
+
sql_pattern = None
|
712
|
+
if pattern:
|
713
|
+
# Simple conversion: replace * with % for SQL LIKE
|
714
|
+
sql_pattern = pattern.replace("*", "%")
|
715
|
+
# If no wildcards, add % at both ends for substring search
|
716
|
+
if "%" not in sql_pattern and "_" not in sql_pattern:
|
717
|
+
sql_pattern = f"%{sql_pattern}%"
|
718
|
+
|
719
|
+
return await self.metadata_cache.get_data_entities(
|
720
|
+
name_pattern=sql_pattern,
|
721
|
+
entity_category=entity_category,
|
722
|
+
data_service_enabled=data_service_enabled,
|
723
|
+
# Note: v2 cache doesn't support data_management_enabled or is_read_only filters yet
|
724
|
+
)
|
725
|
+
return []
|
726
|
+
|
727
|
+
async def fallback_search():
|
728
|
+
return await self.metadata_api_ops.search_data_entities(
|
729
|
+
pattern,
|
730
|
+
entity_category,
|
731
|
+
data_service_enabled,
|
732
|
+
data_management_enabled,
|
733
|
+
is_read_only,
|
734
|
+
)
|
735
|
+
|
736
|
+
entities = await self._get_from_cache_first(
|
737
|
+
cache_search,
|
738
|
+
fallback_search,
|
739
|
+
use_cache_first=use_cache_first or self.config.use_cache_first,
|
740
|
+
)
|
741
|
+
|
742
|
+
if self.metadata_cache:
|
743
|
+
entities = await resolve_labels_generic(entities, self.label_ops)
|
744
|
+
|
745
|
+
return entities
|
746
|
+
|
747
|
+
async def get_data_entity_info(
|
748
|
+
self,
|
749
|
+
entity_name: str,
|
750
|
+
resolve_labels: bool = True,
|
751
|
+
language: str = "en-US",
|
752
|
+
use_cache_first: Optional[bool] = None,
|
753
|
+
) -> Optional[DataEntityInfo]:
|
754
|
+
"""Get detailed information about a specific data entity with cache-first approach
|
755
|
+
|
756
|
+
Args:
|
757
|
+
entity_name: Name of the data entity
|
758
|
+
resolve_labels: Whether to resolve label IDs to text
|
759
|
+
language: Language for label resolution
|
760
|
+
use_cache_first: Override config setting for cache-first behavior
|
761
|
+
|
762
|
+
Returns:
|
763
|
+
DataEntityInfo object or None if not found
|
764
|
+
"""
|
765
|
+
|
766
|
+
async def cache_lookup():
|
767
|
+
if self.metadata_cache:
|
768
|
+
# Get all entities and filter by name (since v2 cache doesn't have get_single method yet)
|
769
|
+
entities = await self.metadata_cache.get_data_entities(
|
770
|
+
name_pattern=entity_name
|
771
|
+
)
|
772
|
+
for entity in entities:
|
773
|
+
if entity.name == entity_name:
|
774
|
+
return entity
|
775
|
+
return None
|
776
|
+
|
777
|
+
async def fallback_lookup():
|
778
|
+
return await self.metadata_api_ops.get_data_entity_info(
|
779
|
+
entity_name, resolve_labels, language
|
780
|
+
)
|
781
|
+
|
782
|
+
return await self._get_from_cache_first(
|
783
|
+
cache_lookup, fallback_lookup, use_cache_first=use_cache_first
|
784
|
+
)
|
785
|
+
|
786
|
+
async def get_public_entities(
|
787
|
+
self, options: Optional[QueryOptions] = None
|
788
|
+
) -> Dict[str, Any]:
|
789
|
+
"""Get public entities from PublicEntities metadata endpoint
|
790
|
+
|
791
|
+
Args:
|
792
|
+
options: OData query options
|
793
|
+
|
794
|
+
Returns:
|
795
|
+
Response containing public entities
|
796
|
+
"""
|
797
|
+
return await self.metadata_api_ops.get_public_entities(options)
|
798
|
+
|
799
|
+
async def search_public_entities(
|
800
|
+
self,
|
801
|
+
pattern: str = "",
|
802
|
+
is_read_only: Optional[bool] = None,
|
803
|
+
configuration_enabled: Optional[bool] = None,
|
804
|
+
use_cache_first: Optional[bool] = None,
|
805
|
+
) -> List[PublicEntityInfo]:
|
806
|
+
"""Search public entities with filtering and cache-first approach
|
807
|
+
|
808
|
+
Args:
|
809
|
+
pattern: Search pattern for entity name (regex supported)
|
810
|
+
is_read_only: Filter by read-only status
|
811
|
+
configuration_enabled: Filter by configuration enabled status
|
812
|
+
use_cache_first: Override config setting for cache-first behavior
|
813
|
+
|
814
|
+
Returns:
|
815
|
+
List of matching public entities (without detailed properties)
|
816
|
+
"""
|
817
|
+
|
818
|
+
async def cache_search():
|
819
|
+
# TODO: v2 cache doesn't have public entity search yet - will be implemented in future phase
|
820
|
+
# For now, always return empty to force fallback to API
|
821
|
+
return []
|
822
|
+
|
823
|
+
async def fallback_search():
|
824
|
+
return await self.metadata_api_ops.search_public_entities(
|
825
|
+
pattern, is_read_only, configuration_enabled
|
826
|
+
)
|
827
|
+
|
828
|
+
return await self._get_from_cache_first(
|
829
|
+
cache_search, fallback_search, use_cache_first=use_cache_first
|
830
|
+
)
|
831
|
+
|
832
|
+
async def get_public_entity_info(
|
833
|
+
self,
|
834
|
+
entity_name: str,
|
835
|
+
resolve_labels: bool = True,
|
836
|
+
language: str = "en-US",
|
837
|
+
use_cache_first: Optional[bool] = True,
|
838
|
+
) -> Optional[PublicEntityInfo]:
|
839
|
+
"""Get detailed information about a specific public entity with cache-first approach
|
840
|
+
|
841
|
+
Args:
|
842
|
+
entity_name: Name of the public entity
|
843
|
+
resolve_labels: Whether to resolve label IDs to text
|
844
|
+
language: Language for label resolution
|
845
|
+
use_cache_first: Override config setting for cache-first behavior
|
846
|
+
|
847
|
+
Returns:
|
848
|
+
PublicEntityInfo object with full details or None if not found
|
849
|
+
"""
|
850
|
+
|
851
|
+
async def cache_lookup():
|
852
|
+
if self.metadata_cache:
|
853
|
+
return await self.metadata_cache.get_public_entity_schema(entity_name)
|
854
|
+
return None
|
855
|
+
|
856
|
+
async def fallback_lookup():
|
857
|
+
return await self.metadata_api_ops.get_public_entity_info(
|
858
|
+
entity_name, resolve_labels, language
|
859
|
+
)
|
860
|
+
|
861
|
+
entity = await self._get_from_cache_first(
|
862
|
+
cache_lookup,
|
863
|
+
fallback_lookup,
|
864
|
+
use_cache_first=use_cache_first or self.config.use_cache_first,
|
865
|
+
)
|
866
|
+
|
867
|
+
return await resolve_labels_generic(entity, self.label_ops)
|
868
|
+
|
869
|
+
async def get_all_public_entities_with_details(
|
870
|
+
self, resolve_labels: bool = False, language: str = "en-US"
|
871
|
+
) -> List[PublicEntityInfo]:
|
872
|
+
"""Get all public entities with full details in a single optimized call
|
873
|
+
|
874
|
+
This method uses an optimized approach that gets all entity details in one API call
|
875
|
+
instead of making individual requests for each entity.
|
876
|
+
|
877
|
+
Args:
|
878
|
+
resolve_labels: Whether to resolve label IDs to text
|
879
|
+
language: Language for label resolution
|
880
|
+
|
881
|
+
Returns:
|
882
|
+
List of PublicEntityInfo objects with complete details
|
883
|
+
"""
|
884
|
+
return await self.metadata_api_ops.get_all_public_entities_with_details(
|
885
|
+
resolve_labels, language
|
886
|
+
)
|
887
|
+
|
888
|
+
async def get_public_enumerations(
|
889
|
+
self, options: Optional[QueryOptions] = None
|
890
|
+
) -> List[EnumerationInfo]:
|
891
|
+
"""Get public enumerations from PublicEnumerations metadata endpoint
|
892
|
+
|
893
|
+
Args:
|
894
|
+
options: OData query options
|
895
|
+
|
896
|
+
Returns:
|
897
|
+
Response containing public enumerations
|
898
|
+
"""
|
899
|
+
self._ensure_metadata_initialized()
|
900
|
+
|
901
|
+
return await self.metadata_api_ops.get_public_enumerations(options)
|
902
|
+
|
903
|
+
async def search_public_enumerations(
|
904
|
+
self, pattern: str = "", use_cache_first: Optional[bool] = True
|
905
|
+
) -> List[EnumerationInfo]:
|
906
|
+
"""Search public enumerations with filtering and cache-first approach
|
907
|
+
|
908
|
+
Args:
|
909
|
+
pattern: Search pattern for enumeration name (regex supported)
|
910
|
+
use_cache_first: Override config setting for cache-first behavior
|
911
|
+
|
912
|
+
Returns:
|
913
|
+
List of matching enumerations (without detailed members)
|
914
|
+
"""
|
915
|
+
|
916
|
+
async def cache_search():
|
917
|
+
# TODO: v2 cache doesn't have enumeration search yet - will be implemented in future phase
|
918
|
+
# For now, always return empty to force fallback to API
|
919
|
+
return []
|
920
|
+
|
921
|
+
async def fallback_search():
|
922
|
+
return await self.metadata_api_ops.search_public_enumerations(pattern)
|
923
|
+
|
924
|
+
enums = await self._get_from_cache_first(
|
925
|
+
cache_search,
|
926
|
+
fallback_search,
|
927
|
+
use_cache_first=use_cache_first or self.config.use_cache_first,
|
928
|
+
)
|
929
|
+
|
930
|
+
return await resolve_labels_generic(enums, self.label_ops)
|
931
|
+
|
932
|
+
async def get_public_enumeration_info(
|
933
|
+
self,
|
934
|
+
enumeration_name: str,
|
935
|
+
resolve_labels: bool = True,
|
936
|
+
language: str = "en-US",
|
937
|
+
use_cache_first: Optional[bool] = True,
|
938
|
+
) -> Optional[EnumerationInfo]:
|
939
|
+
"""Get detailed information about a specific public enumeration with cache-first approach
|
940
|
+
|
941
|
+
Args:
|
942
|
+
enumeration_name: Name of the enumeration
|
943
|
+
resolve_labels: Whether to resolve label IDs to text
|
944
|
+
language: Language for label resolution
|
945
|
+
use_cache_first: Override config setting for cache-first behavior
|
946
|
+
|
947
|
+
Returns:
|
948
|
+
EnumerationInfo object with full details or None if not found
|
949
|
+
"""
|
950
|
+
|
951
|
+
async def cache_lookup():
|
952
|
+
if self.metadata_cache:
|
953
|
+
return await self.metadata_cache.get_enumeration_info(enumeration_name)
|
954
|
+
return None
|
955
|
+
|
956
|
+
async def fallback_lookup():
|
957
|
+
return await self.metadata_api_ops.get_public_enumeration_info(
|
958
|
+
enumeration_name, resolve_labels, language
|
959
|
+
)
|
960
|
+
|
961
|
+
enum = await self._get_from_cache_first(
|
962
|
+
cache_lookup,
|
963
|
+
fallback_lookup,
|
964
|
+
use_cache_first=use_cache_first or self.config.use_cache_first,
|
965
|
+
)
|
966
|
+
return await resolve_labels_generic(enum, self.label_ops) if enum else None
|
967
|
+
|
968
|
+
async def get_all_public_enumerations_with_details(
|
969
|
+
self, resolve_labels: bool = False, language: str = "en-US"
|
970
|
+
) -> List[EnumerationInfo]:
|
971
|
+
"""Get all public enumerations with full details in a single optimized call
|
972
|
+
|
973
|
+
This method uses an optimized approach that gets all enumeration details in one API call
|
974
|
+
instead of making individual requests for each enumeration.
|
975
|
+
|
976
|
+
Args:
|
977
|
+
resolve_labels: Whether to resolve label IDs to text
|
978
|
+
language: Language for label resolution
|
979
|
+
|
980
|
+
Returns:
|
981
|
+
List of EnumerationInfo objects with complete details
|
982
|
+
"""
|
983
|
+
return await self.metadata_api_ops.get_all_public_enumerations_with_details(
|
984
|
+
resolve_labels, language
|
985
|
+
)
|
986
|
+
|
987
|
+
# Utility Methods
|
988
|
+
|
989
|
+
def get_label_cache_info(self) -> Dict[str, Any]:
|
990
|
+
"""Get label cache information and statistics
|
991
|
+
|
992
|
+
Returns:
|
993
|
+
Dictionary with label cache information
|
994
|
+
"""
|
995
|
+
if not self.config.enable_metadata_cache or not self._metadata_initialized:
|
996
|
+
return {
|
997
|
+
"enabled": False,
|
998
|
+
"cache_type": "none",
|
999
|
+
"message": "Metadata cache not enabled or not initialized",
|
1000
|
+
}
|
1001
|
+
|
1002
|
+
if hasattr(self.metadata_cache, "get_label_cache_statistics"):
|
1003
|
+
# Using v2 cache with label support
|
1004
|
+
try:
|
1005
|
+
import asyncio
|
1006
|
+
|
1007
|
+
# Get the current event loop or create a new one
|
1008
|
+
try:
|
1009
|
+
loop = asyncio.get_event_loop()
|
1010
|
+
except RuntimeError:
|
1011
|
+
loop = asyncio.new_event_loop()
|
1012
|
+
asyncio.set_event_loop(loop)
|
1013
|
+
|
1014
|
+
if loop.is_running():
|
1015
|
+
# If we're in an async context, we can't run async code synchronously
|
1016
|
+
return {
|
1017
|
+
"enabled": True,
|
1018
|
+
"cache_type": "metadata_v2",
|
1019
|
+
"message": "Label caching enabled with v2 cache (statistics available via async method)",
|
1020
|
+
}
|
1021
|
+
else:
|
1022
|
+
# If we're not in an async context, we can get the statistics
|
1023
|
+
stats = loop.run_until_complete(
|
1024
|
+
self.metadata_cache.get_label_cache_statistics()
|
1025
|
+
)
|
1026
|
+
return {
|
1027
|
+
"enabled": True,
|
1028
|
+
"cache_type": "metadata_v2",
|
1029
|
+
"statistics": stats,
|
1030
|
+
}
|
1031
|
+
except Exception as e:
|
1032
|
+
return {
|
1033
|
+
"enabled": True,
|
1034
|
+
"cache_type": "metadata_v2",
|
1035
|
+
"error": f"Error getting statistics: {e}",
|
1036
|
+
}
|
1037
|
+
else:
|
1038
|
+
# Legacy cache or no label caching
|
1039
|
+
return {
|
1040
|
+
"enabled": False,
|
1041
|
+
"cache_type": "legacy" if self.metadata_cache else "none",
|
1042
|
+
"message": "Label caching not supported by current cache implementation",
|
1043
|
+
}
|
1044
|
+
|
1045
|
+
async def get_label_cache_info_async(self) -> Dict[str, Any]:
|
1046
|
+
"""Get label cache information and statistics (async version)
|
1047
|
+
|
1048
|
+
Returns:
|
1049
|
+
Dictionary with label cache information
|
1050
|
+
"""
|
1051
|
+
if not self.config.enable_metadata_cache or not self._metadata_initialized:
|
1052
|
+
return {
|
1053
|
+
"enabled": False,
|
1054
|
+
"cache_type": "none",
|
1055
|
+
"message": "Metadata cache not enabled or not initialized",
|
1056
|
+
}
|
1057
|
+
|
1058
|
+
if hasattr(self.metadata_cache, "get_label_cache_statistics"):
|
1059
|
+
# Using v2 cache with label support
|
1060
|
+
try:
|
1061
|
+
stats = await self.metadata_cache.get_label_cache_statistics()
|
1062
|
+
return {
|
1063
|
+
"enabled": True,
|
1064
|
+
"cache_type": "metadata_v2",
|
1065
|
+
"statistics": stats,
|
1066
|
+
}
|
1067
|
+
except Exception as e:
|
1068
|
+
return {
|
1069
|
+
"enabled": True,
|
1070
|
+
"cache_type": "metadata_v2",
|
1071
|
+
"error": f"Error getting statistics: {e}",
|
1072
|
+
}
|
1073
|
+
else:
|
1074
|
+
# Legacy cache or no label caching
|
1075
|
+
return {
|
1076
|
+
"enabled": False,
|
1077
|
+
"cache_type": "legacy" if self.metadata_cache else "none",
|
1078
|
+
"message": "Label caching not supported by current cache implementation",
|
1079
|
+
}
|
1080
|
+
|
1081
|
+
def get_entity_url(
|
1082
|
+
self, entity_name: str, key: Optional[Union[str, Dict[str, Any]]] = None
|
1083
|
+
) -> str:
|
1084
|
+
"""Get entity URL
|
1085
|
+
|
1086
|
+
Args:
|
1087
|
+
entity_name: Entity set name
|
1088
|
+
key: Optional entity key (string for simple keys, dict for composite keys)
|
1089
|
+
|
1090
|
+
Returns:
|
1091
|
+
Complete entity URL
|
1092
|
+
"""
|
1093
|
+
return QueryBuilder.build_entity_url(self.config.base_url, entity_name, key)
|
1094
|
+
|
1095
|
+
def get_action_url(
|
1096
|
+
self,
|
1097
|
+
action_name: str,
|
1098
|
+
entity_name: Optional[str] = None,
|
1099
|
+
entity_key: Optional[Union[str, Dict[str, Any]]] = None,
|
1100
|
+
) -> str:
|
1101
|
+
"""Get action URL
|
1102
|
+
|
1103
|
+
Args:
|
1104
|
+
action_name: Action name
|
1105
|
+
entity_name: Optional entity name for bound actions
|
1106
|
+
entity_key: Optional entity key for bound actions (string for simple keys, dict for composite keys)
|
1107
|
+
|
1108
|
+
Returns:
|
1109
|
+
Complete action URL
|
1110
|
+
"""
|
1111
|
+
return QueryBuilder.build_action_url(
|
1112
|
+
self.config.base_url, action_name, entity_name, entity_key
|
1113
|
+
)
|
1114
|
+
|
1115
|
+
async def get_metadata_info(self) -> Dict[str, Any]:
|
1116
|
+
"""Get metadata cache information
|
1117
|
+
|
1118
|
+
Returns:
|
1119
|
+
Dictionary with metadata information
|
1120
|
+
"""
|
1121
|
+
# Start with basic info
|
1122
|
+
info = {
|
1123
|
+
"cache_directory": self.config.metadata_cache_dir,
|
1124
|
+
"cache_version": "2.0",
|
1125
|
+
"statistics": None,
|
1126
|
+
}
|
1127
|
+
|
1128
|
+
await self._ensure_metadata_initialized()
|
1129
|
+
|
1130
|
+
# Add new metadata cache v2 info if available
|
1131
|
+
if self.metadata_cache:
|
1132
|
+
try:
|
1133
|
+
stats = await self.metadata_cache.get_cache_statistics()
|
1134
|
+
cache_info = {
|
1135
|
+
"advanced_cache_enabled": True,
|
1136
|
+
"cache_v2_enabled": True,
|
1137
|
+
"cache_initialized": self._metadata_initialized,
|
1138
|
+
"sync_manager_available": self.sync_manager is not None,
|
1139
|
+
"background_sync_running": (
|
1140
|
+
(
|
1141
|
+
self._background_sync_task
|
1142
|
+
and not self._background_sync_task.done()
|
1143
|
+
)
|
1144
|
+
if self._background_sync_task
|
1145
|
+
else False
|
1146
|
+
),
|
1147
|
+
"statistics": stats,
|
1148
|
+
}
|
1149
|
+
info.update(cache_info)
|
1150
|
+
except Exception as e:
|
1151
|
+
self.logger.warning(f"Error getting cache v2 info: {e}")
|
1152
|
+
# Even on error, include basic cache info
|
1153
|
+
info.update(
|
1154
|
+
{
|
1155
|
+
"advanced_cache_enabled": True,
|
1156
|
+
"cache_v2_enabled": True,
|
1157
|
+
"cache_initialized": self._metadata_initialized,
|
1158
|
+
"sync_manager_available": False,
|
1159
|
+
"background_sync_running": False,
|
1160
|
+
}
|
1161
|
+
)
|
1162
|
+
else:
|
1163
|
+
info.update(
|
1164
|
+
{
|
1165
|
+
"advanced_cache_enabled": False,
|
1166
|
+
"cache_v2_enabled": False,
|
1167
|
+
"cache_initialized": False,
|
1168
|
+
"sync_manager_available": False,
|
1169
|
+
"background_sync_running": False,
|
1170
|
+
}
|
1171
|
+
)
|
1172
|
+
|
1173
|
+
return info
|
1174
|
+
|
1175
|
+
# Application Version Operations
|
1176
|
+
|
1177
|
+
async def get_entity_schema(self, entity_name: str) -> Optional[PublicEntityInfo]:
|
1178
|
+
"""Get entity schema - compatibility method for SmartSyncManagerV2
|
1179
|
+
|
1180
|
+
Args:
|
1181
|
+
entity_name: Name of the public entity
|
1182
|
+
|
1183
|
+
Returns:
|
1184
|
+
PublicEntityInfo object with schema details or None if not found
|
1185
|
+
"""
|
1186
|
+
return await self.metadata_api_ops.get_public_entity_info(
|
1187
|
+
entity_name, resolve_labels=False
|
1188
|
+
)
|
1189
|
+
|
1190
|
+
async def get_application_version(self) -> str:
|
1191
|
+
"""Get the current application version of the D365 F&O environment
|
1192
|
+
|
1193
|
+
This method calls the GetApplicationVersion action bound to the DataManagementEntities
|
1194
|
+
collection to retrieve the application version information.
|
1195
|
+
|
1196
|
+
Returns:
|
1197
|
+
str: The application version string
|
1198
|
+
|
1199
|
+
Raises:
|
1200
|
+
FOClientError: If the action call fails
|
1201
|
+
"""
|
1202
|
+
try:
|
1203
|
+
return await self.metadata_api_ops.get_application_version()
|
1204
|
+
|
1205
|
+
except Exception as e:
|
1206
|
+
raise FOClientError(f"Failed to get application version: {e}")
|
1207
|
+
|
1208
|
+
async def get_platform_build_version(self) -> str:
|
1209
|
+
"""Get the current platform build version of the D365 F&O environment
|
1210
|
+
|
1211
|
+
This method calls the GetPlatformBuildVersion action bound to the DataManagementEntities
|
1212
|
+
collection to retrieve the platform build version information.
|
1213
|
+
|
1214
|
+
Returns:
|
1215
|
+
str: The platform build version string
|
1216
|
+
|
1217
|
+
Raises:
|
1218
|
+
FOClientError: If the action call fails
|
1219
|
+
"""
|
1220
|
+
try:
|
1221
|
+
return await self.metadata_api_ops.get_platform_build_version()
|
1222
|
+
|
1223
|
+
except Exception as e:
|
1224
|
+
raise FOClientError(f"Failed to get platform build version: {e}")
|
1225
|
+
|
1226
|
+
async def get_application_build_version(self) -> str:
|
1227
|
+
"""Get the current application build version of the D365 F&O environment
|
1228
|
+
|
1229
|
+
This method calls the GetApplicationBuildVersion action bound to the DataManagementEntities
|
1230
|
+
collection to retrieve the application build version information.
|
1231
|
+
|
1232
|
+
Returns:
|
1233
|
+
str: The application build version string
|
1234
|
+
|
1235
|
+
Raises:
|
1236
|
+
FOClientError: If the action call fails
|
1237
|
+
"""
|
1238
|
+
try:
|
1239
|
+
result = await self.call_action(
|
1240
|
+
"GetApplicationBuildVersion",
|
1241
|
+
parameters=None,
|
1242
|
+
entity_name="DataManagementEntities",
|
1243
|
+
)
|
1244
|
+
|
1245
|
+
# The action returns a simple string value
|
1246
|
+
if isinstance(result, str):
|
1247
|
+
return result
|
1248
|
+
elif isinstance(result, dict) and "value" in result:
|
1249
|
+
return str(result["value"])
|
1250
|
+
else:
|
1251
|
+
return str(result) if result is not None else ""
|
1252
|
+
|
1253
|
+
except Exception as e:
|
1254
|
+
raise FOClientError(f"Failed to get application build version: {e}")
|
1255
|
+
|
1256
|
+
async def get_installed_modules(self) -> List[str]:
|
1257
|
+
"""Get the list of installed modules in the D365 F&O environment
|
1258
|
+
|
1259
|
+
This method calls the GetInstalledModules action bound to the DataManagementEntities
|
1260
|
+
collection to retrieve the list of installed modules with their details.
|
1261
|
+
|
1262
|
+
Returns:
|
1263
|
+
List[str]: List of module strings in format:
|
1264
|
+
"Name: {name} | Version: {version} | Module: {module_id} | Publisher: {publisher} | DisplayName: {display_name}"
|
1265
|
+
|
1266
|
+
Raises:
|
1267
|
+
FOClientError: If the action call fails
|
1268
|
+
"""
|
1269
|
+
try:
|
1270
|
+
return await self.metadata_api_ops.get_installed_modules()
|
1271
|
+
|
1272
|
+
except Exception as e:
|
1273
|
+
raise FOClientError(f"Failed to get installed modules: {e}")
|
1274
|
+
|
1275
|
+
async def query_data_management_entities(
|
1276
|
+
self,
|
1277
|
+
category_filters: Optional[List[int]] = None,
|
1278
|
+
config_key_filters: Optional[List[str]] = None,
|
1279
|
+
country_region_code_filters: Optional[List[str]] = None,
|
1280
|
+
is_shared_filters: Optional[List[int]] = None,
|
1281
|
+
module_filters: Optional[List[str]] = None,
|
1282
|
+
tag_filters: Optional[List[str]] = None,
|
1283
|
+
) -> List[Dict[str, Any]]:
|
1284
|
+
"""Query data management entities using the OData query action
|
1285
|
+
|
1286
|
+
This method calls the 'query' action bound to the DataManagementEntities
|
1287
|
+
collection to retrieve filtered data management entities based on various criteria.
|
1288
|
+
|
1289
|
+
Args:
|
1290
|
+
category_filters: Filter by entity category IDs (integers - e.g., [0, 1, 2])
|
1291
|
+
0=Master, 1=Configuration, 2=Transaction, 3=Reference, 4=Document, 5=Parameters
|
1292
|
+
config_key_filters: Filter by configuration keys (strings)
|
1293
|
+
country_region_code_filters: Filter by country/region codes (strings)
|
1294
|
+
is_shared_filters: Filter by shared status (integers - 0=No, 1=Yes)
|
1295
|
+
module_filters: Filter by module names (strings)
|
1296
|
+
tag_filters: Filter by tags (strings)
|
1297
|
+
|
1298
|
+
Returns:
|
1299
|
+
List[Dict[str, Any]]: List of data management entity information
|
1300
|
+
|
1301
|
+
Raises:
|
1302
|
+
FOClientError: If the action call fails
|
1303
|
+
|
1304
|
+
Note:
|
1305
|
+
All parameters must be passed as arrays/lists since they are collection parameters in the OData action.
|
1306
|
+
The categoryFilters and isSharedFilters parameters expect integers, while other filters expect strings.
|
1307
|
+
To query all entities, use empty lists [] for required parameters or omit optional ones.
|
1308
|
+
|
1309
|
+
Category enum values:
|
1310
|
+
- 0 = Master
|
1311
|
+
- 1 = Configuration
|
1312
|
+
- 2 = Transaction
|
1313
|
+
- 3 = Reference
|
1314
|
+
- 4 = Document
|
1315
|
+
- 5 = Parameters
|
1316
|
+
|
1317
|
+
IsShared enum values:
|
1318
|
+
- 0 = No
|
1319
|
+
- 1 = Yes
|
1320
|
+
"""
|
1321
|
+
try:
|
1322
|
+
# Prepare parameters for the query action
|
1323
|
+
# All parameters are collections (arrays) as per the OData action definition
|
1324
|
+
parameters = {}
|
1325
|
+
|
1326
|
+
# Required parameters with default empty arrays if not provided
|
1327
|
+
parameters["categoryFilters"] = (
|
1328
|
+
category_filters if category_filters is not None else []
|
1329
|
+
)
|
1330
|
+
parameters["isSharedFilters"] = (
|
1331
|
+
is_shared_filters if is_shared_filters is not None else []
|
1332
|
+
)
|
1333
|
+
|
1334
|
+
# Optional collection parameters
|
1335
|
+
if config_key_filters is not None:
|
1336
|
+
parameters["configKeyFilters"] = config_key_filters
|
1337
|
+
else:
|
1338
|
+
parameters["configKeyFilters"] = []
|
1339
|
+
|
1340
|
+
if country_region_code_filters is not None:
|
1341
|
+
parameters["countryRegionCodeFilters"] = country_region_code_filters
|
1342
|
+
else:
|
1343
|
+
parameters["countryRegionCodeFilters"] = []
|
1344
|
+
|
1345
|
+
if module_filters is not None:
|
1346
|
+
parameters["moduleFilters"] = module_filters
|
1347
|
+
else:
|
1348
|
+
parameters["moduleFilters"] = []
|
1349
|
+
|
1350
|
+
if tag_filters is not None:
|
1351
|
+
parameters["tagFilters"] = tag_filters
|
1352
|
+
else:
|
1353
|
+
parameters["tagFilters"] = []
|
1354
|
+
|
1355
|
+
# Call the query action bound to DataManagementEntities
|
1356
|
+
result = await self.call_action(
|
1357
|
+
"query", parameters=parameters, entity_name="DataManagementEntities"
|
1358
|
+
)
|
1359
|
+
|
1360
|
+
# The action returns a collection of DataManagementEntity objects
|
1361
|
+
if isinstance(result, dict):
|
1362
|
+
if "value" in result:
|
1363
|
+
# Standard OData response format
|
1364
|
+
return result["value"]
|
1365
|
+
elif "@odata.context" in result:
|
1366
|
+
# Response might be the entities directly
|
1367
|
+
entities = []
|
1368
|
+
for key, value in result.items():
|
1369
|
+
if not key.startswith("@"):
|
1370
|
+
entities.append(value)
|
1371
|
+
return entities
|
1372
|
+
else:
|
1373
|
+
# Response is the result directly
|
1374
|
+
return [result] if result else []
|
1375
|
+
elif isinstance(result, list):
|
1376
|
+
# Direct list of entities
|
1377
|
+
return result
|
1378
|
+
else:
|
1379
|
+
# Unexpected format, return empty list
|
1380
|
+
self.logger.warning(f"Unexpected query response format: {type(result)}")
|
1381
|
+
return []
|
1382
|
+
|
1383
|
+
except Exception as e:
|
1384
|
+
raise FOClientError(f"Failed to query data management entities: {e}")
|
1385
|
+
|
1386
|
+
async def query_data_management_entities_by_category(
|
1387
|
+
self,
|
1388
|
+
categories: List[str],
|
1389
|
+
is_shared: Optional[bool] = None,
|
1390
|
+
modules: Optional[List[str]] = None,
|
1391
|
+
) -> List[Dict[str, Any]]:
|
1392
|
+
"""Query data management entities by category names (convenience method)
|
1393
|
+
|
1394
|
+
This is a convenience method that converts category names to their integer enum values
|
1395
|
+
and calls the main query_data_management_entities method.
|
1396
|
+
|
1397
|
+
Args:
|
1398
|
+
categories: List of category names (e.g., ['Master', 'Transaction'])
|
1399
|
+
Valid values: Master, Configuration, Transaction, Reference, Document, Parameters
|
1400
|
+
is_shared: Optional boolean filter for shared status (True/False)
|
1401
|
+
modules: Optional list of module names to filter by
|
1402
|
+
|
1403
|
+
Returns:
|
1404
|
+
List[Dict[str, Any]]: List of data management entity information
|
1405
|
+
|
1406
|
+
Raises:
|
1407
|
+
FOClientError: If the action call fails
|
1408
|
+
ValueError: If invalid category names are provided
|
1409
|
+
"""
|
1410
|
+
# Mapping of category names to enum values
|
1411
|
+
category_map = {
|
1412
|
+
"Master": 0,
|
1413
|
+
"Configuration": 1,
|
1414
|
+
"Transaction": 2,
|
1415
|
+
"Reference": 3,
|
1416
|
+
"Document": 4,
|
1417
|
+
"Parameters": 5,
|
1418
|
+
}
|
1419
|
+
|
1420
|
+
# Convert category names to enum values
|
1421
|
+
category_filters = []
|
1422
|
+
for category in categories:
|
1423
|
+
if category not in category_map:
|
1424
|
+
raise ValueError(
|
1425
|
+
f"Invalid category '{category}'. Valid categories: {list(category_map.keys())}"
|
1426
|
+
)
|
1427
|
+
category_filters.append(category_map[category])
|
1428
|
+
|
1429
|
+
# Convert is_shared boolean to enum value
|
1430
|
+
is_shared_filters = None
|
1431
|
+
if is_shared is not None:
|
1432
|
+
is_shared_filters = [1 if is_shared else 0]
|
1433
|
+
|
1434
|
+
# Call the main query method with converted values
|
1435
|
+
return await self.query_data_management_entities(
|
1436
|
+
category_filters=category_filters,
|
1437
|
+
is_shared_filters=is_shared_filters,
|
1438
|
+
module_filters=modules,
|
1439
|
+
)
|
1440
|
+
|
1441
|
+
|
1442
|
+
# Convenience function for creating client
|
1443
|
+
def create_client(base_url: str, **kwargs) -> FOClient:
|
1444
|
+
"""Create F&O client with convenience parameters
|
1445
|
+
|
1446
|
+
Args:
|
1447
|
+
base_url: F&O base URL
|
1448
|
+
**kwargs: Additional configuration parameters
|
1449
|
+
|
1450
|
+
Returns:
|
1451
|
+
Configured FOClient instance
|
1452
|
+
"""
|
1453
|
+
config = FOClientConfig(base_url=base_url, **kwargs)
|
1454
|
+
return FOClient(config)
|