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.
Files changed (51) hide show
  1. d365fo_client/__init__.py +305 -0
  2. d365fo_client/auth.py +93 -0
  3. d365fo_client/cli.py +700 -0
  4. d365fo_client/client.py +1454 -0
  5. d365fo_client/config.py +304 -0
  6. d365fo_client/crud.py +200 -0
  7. d365fo_client/exceptions.py +49 -0
  8. d365fo_client/labels.py +528 -0
  9. d365fo_client/main.py +502 -0
  10. d365fo_client/mcp/__init__.py +16 -0
  11. d365fo_client/mcp/client_manager.py +276 -0
  12. d365fo_client/mcp/main.py +98 -0
  13. d365fo_client/mcp/models.py +371 -0
  14. d365fo_client/mcp/prompts/__init__.py +43 -0
  15. d365fo_client/mcp/prompts/action_execution.py +480 -0
  16. d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
  17. d365fo_client/mcp/resources/__init__.py +15 -0
  18. d365fo_client/mcp/resources/database_handler.py +555 -0
  19. d365fo_client/mcp/resources/entity_handler.py +176 -0
  20. d365fo_client/mcp/resources/environment_handler.py +132 -0
  21. d365fo_client/mcp/resources/metadata_handler.py +283 -0
  22. d365fo_client/mcp/resources/query_handler.py +135 -0
  23. d365fo_client/mcp/server.py +432 -0
  24. d365fo_client/mcp/tools/__init__.py +17 -0
  25. d365fo_client/mcp/tools/connection_tools.py +175 -0
  26. d365fo_client/mcp/tools/crud_tools.py +579 -0
  27. d365fo_client/mcp/tools/database_tools.py +813 -0
  28. d365fo_client/mcp/tools/label_tools.py +189 -0
  29. d365fo_client/mcp/tools/metadata_tools.py +766 -0
  30. d365fo_client/mcp/tools/profile_tools.py +706 -0
  31. d365fo_client/metadata_api.py +793 -0
  32. d365fo_client/metadata_v2/__init__.py +59 -0
  33. d365fo_client/metadata_v2/cache_v2.py +1372 -0
  34. d365fo_client/metadata_v2/database_v2.py +585 -0
  35. d365fo_client/metadata_v2/global_version_manager.py +573 -0
  36. d365fo_client/metadata_v2/search_engine_v2.py +423 -0
  37. d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
  38. d365fo_client/metadata_v2/version_detector.py +439 -0
  39. d365fo_client/models.py +862 -0
  40. d365fo_client/output.py +181 -0
  41. d365fo_client/profile_manager.py +342 -0
  42. d365fo_client/profiles.py +178 -0
  43. d365fo_client/query.py +162 -0
  44. d365fo_client/session.py +60 -0
  45. d365fo_client/utils.py +196 -0
  46. d365fo_client-0.1.0.dist-info/METADATA +1084 -0
  47. d365fo_client-0.1.0.dist-info/RECORD +51 -0
  48. d365fo_client-0.1.0.dist-info/WHEEL +5 -0
  49. d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
  50. d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -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)