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,793 @@
1
+ """Metadata API operations for D365 F&O client."""
2
+
3
+ import logging
4
+ import re
5
+ from typing import Any, Dict, List, Optional, Union
6
+
7
+ from d365fo_client.crud import CrudOperations
8
+
9
+ from .labels import LabelOperations
10
+ from .models import (
11
+ ActionParameterInfo,
12
+ ActionParameterTypeInfo,
13
+ ActionReturnTypeInfo,
14
+ DataEntityInfo,
15
+ EnumerationInfo,
16
+ EnumerationMemberInfo,
17
+ NavigationPropertyInfo,
18
+ PropertyGroupInfo,
19
+ PublicEntityActionInfo,
20
+ PublicEntityInfo,
21
+ PublicEntityPropertyInfo,
22
+ QueryOptions,
23
+ ReferentialConstraintInfo,
24
+ )
25
+ from .query import QueryBuilder
26
+ from .session import SessionManager
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class MetadataAPIOperations:
32
+ """Operations for metadata API endpoints"""
33
+
34
+ def __init__(
35
+ self,
36
+ session_manager: SessionManager,
37
+ metadata_url: str,
38
+ label_ops: Optional[LabelOperations] = None,
39
+ ):
40
+ """Initialize metadata API operations
41
+
42
+ Args:
43
+ session_manager: Session manager for HTTP requests
44
+ metadata_url: Base metadata URL
45
+ label_ops: Label operations for resolving labels
46
+ """
47
+ self.session_manager = session_manager
48
+ self.metadata_url = metadata_url
49
+ self.label_ops = label_ops
50
+ self.crud_ops = CrudOperations(
51
+ self.session_manager, self.session_manager.config.base_url
52
+ )
53
+
54
+ def _parse_public_entity_from_json(self, item: Dict[str, Any]) -> PublicEntityInfo:
55
+ """Parse a public entity from JSON data returned by PublicEntities API
56
+
57
+ Args:
58
+ item: JSON object representing a single public entity
59
+
60
+ Returns:
61
+ PublicEntityInfo object with full details
62
+ """
63
+ # Create entity info
64
+ entity = PublicEntityInfo(
65
+ name=item.get("Name", ""),
66
+ entity_set_name=item.get("EntitySetName", ""),
67
+ label_id=item.get("LabelId"),
68
+ is_read_only=item.get("IsReadOnly", False),
69
+ configuration_enabled=item.get("ConfigurationEnabled", True),
70
+ )
71
+
72
+ # Process properties
73
+ prop_order = 1
74
+ for prop_data in item.get("Properties", []):
75
+ prop = PublicEntityPropertyInfo(
76
+ name=prop_data.get("Name", ""),
77
+ type_name=prop_data.get("TypeName", ""),
78
+ data_type=prop_data.get("DataType", ""),
79
+ odata_xpp_type=prop_data.get("DataType", ""),
80
+ label_id=prop_data.get("LabelId"),
81
+ is_key=prop_data.get("IsKey", False),
82
+ is_mandatory=prop_data.get("IsMandatory", False),
83
+ configuration_enabled=prop_data.get("ConfigurationEnabled", True),
84
+ allow_edit=prop_data.get("AllowEdit", True),
85
+ allow_edit_on_create=prop_data.get("AllowEditOnCreate", True),
86
+ is_dimension=prop_data.get("IsDimension", False),
87
+ dimension_relation=prop_data.get("DimensionRelation"),
88
+ is_dynamic_dimension=prop_data.get("IsDynamicDimension", False),
89
+ dimension_legal_entity_property=prop_data.get(
90
+ "DimensionLegalEntityProperty"
91
+ ),
92
+ dimension_type_property=prop_data.get("DimensionTypeProperty"),
93
+ property_order=prop_order,
94
+ )
95
+ prop_order += 1
96
+ entity.properties.append(prop)
97
+
98
+ # Process navigation properties
99
+ for nav_data in item.get("NavigationProperties", []):
100
+ nav_prop = NavigationPropertyInfo(
101
+ name=nav_data.get("Name", ""),
102
+ related_entity=nav_data.get("RelatedEntity", ""),
103
+ related_relation_name=nav_data.get("RelatedRelationName"),
104
+ cardinality=nav_data.get("Cardinality", "Single"),
105
+ )
106
+
107
+ # Process constraints
108
+ for constraint_data in nav_data.get("Constraints", []):
109
+ # Check for ReferentialConstraint type (most common)
110
+ odata_type = constraint_data.get("@odata.type", "")
111
+ if "ReferentialConstraint" in odata_type:
112
+ constraint = ReferentialConstraintInfo(
113
+ constraint_type="Referential",
114
+ property=constraint_data.get("Property", ""),
115
+ referenced_property=constraint_data.get(
116
+ "ReferencedProperty", ""
117
+ ),
118
+ )
119
+ nav_prop.constraints.append(constraint)
120
+
121
+ entity.navigation_properties.append(nav_prop)
122
+
123
+ # Process property groups
124
+ for group_data in item.get("PropertyGroups", []):
125
+ prop_group = PropertyGroupInfo(
126
+ name=group_data.get("Name", ""),
127
+ properties=group_data.get("Properties", []),
128
+ )
129
+ entity.property_groups.append(prop_group)
130
+
131
+ # Process actions
132
+ for action_data in item.get("Actions", []):
133
+ action = PublicEntityActionInfo(
134
+ name=action_data.get("Name", ""),
135
+ binding_kind=action_data.get("BindingKind", ""),
136
+ field_lookup=action_data.get("FieldLookup"),
137
+ )
138
+
139
+ # Process parameters
140
+ for param_data in action_data.get("Parameters", []):
141
+ param_type_data = param_data.get("Type", {})
142
+ param_type = ActionParameterTypeInfo(
143
+ type_name=param_type_data.get("TypeName", ""),
144
+ is_collection=param_type_data.get("IsCollection", False),
145
+ )
146
+
147
+ param = ActionParameterInfo(
148
+ name=param_data.get("Name", ""), type=param_type
149
+ )
150
+ action.parameters.append(param)
151
+
152
+ # Process return type
153
+ return_type_data = action_data.get("ReturnType")
154
+ if return_type_data:
155
+ action.return_type = ActionReturnTypeInfo(
156
+ type_name=return_type_data.get("TypeName", ""),
157
+ is_collection=return_type_data.get("IsCollection", False),
158
+ )
159
+
160
+ entity.actions.append(action)
161
+
162
+ return entity
163
+
164
+ def _parse_public_enumeration_from_json(
165
+ self, item: Dict[str, Any]
166
+ ) -> EnumerationInfo:
167
+ """Parse a public enumeration from JSON data returned by PublicEnumerations API
168
+
169
+ Args:
170
+ item: JSON object representing a single public enumeration
171
+
172
+ Returns:
173
+ EnumerationInfo object with full details
174
+ """
175
+ # Create enumeration info
176
+ enum = EnumerationInfo(name=item.get("Name", ""), label_id=item.get("LabelId"))
177
+
178
+ # Process members
179
+ for member_data in item.get("Members", []):
180
+ member = EnumerationMemberInfo(
181
+ name=member_data.get("Name", ""),
182
+ value=member_data.get("Value", 0),
183
+ label_id=member_data.get("LabelId"),
184
+ configuration_enabled=member_data.get("ConfigurationEnabled", True),
185
+ )
186
+ enum.members.append(member)
187
+
188
+ return enum
189
+
190
+ # DataEntities endpoint operations
191
+
192
+ async def get_data_entities(
193
+ self, options: Optional[QueryOptions] = None
194
+ ) -> Dict[str, Any]:
195
+ """Get data entities from DataEntities endpoint
196
+
197
+ Args:
198
+ options: OData query options
199
+
200
+ Returns:
201
+ List of DataEntityInfo objects
202
+ """
203
+ session = await self.session_manager.get_session()
204
+ url = f"{self.metadata_url}/DataEntities"
205
+
206
+ params = QueryBuilder.build_query_params(options)
207
+
208
+ async with session.get(url, params=params) as response:
209
+ if response.status == 200:
210
+ data = await response.json()
211
+ return data
212
+ else:
213
+ raise Exception(
214
+ f"Failed to get data entities: {response.status} - {await response.text()}"
215
+ )
216
+
217
+ async def search_data_entities(
218
+ self,
219
+ pattern: str = "",
220
+ entity_category: Optional[str] = None,
221
+ data_service_enabled: Optional[bool] = None,
222
+ data_management_enabled: Optional[bool] = None,
223
+ is_read_only: Optional[bool] = None,
224
+ ) -> List[DataEntityInfo]:
225
+ """Search data entities with filtering
226
+
227
+ Args:
228
+ pattern: Search pattern for entity name (regex supported)
229
+ entity_category: Filter by entity category (e.g., 'Master', 'Transaction')
230
+ data_service_enabled: Filter by data service enabled status
231
+ data_management_enabled: Filter by data management enabled status
232
+ is_read_only: Filter by read-only status
233
+
234
+ Returns:
235
+ List of matching data entities
236
+ """
237
+ # Build OData filter
238
+ filters = []
239
+
240
+ if pattern:
241
+ # Use contains for pattern matching
242
+ filters.append(f"contains(tolower(Name), '{pattern.lower()}')")
243
+
244
+ if entity_category is not None:
245
+ # EntityCategory is an enum, use the correct enum syntax
246
+ filters.append(
247
+ f"EntityCategory eq Microsoft.Dynamics.Metadata.EntityCategory'{entity_category}'"
248
+ )
249
+
250
+ if data_service_enabled is not None:
251
+ filters.append(f"DataServiceEnabled eq {str(data_service_enabled).lower()}")
252
+
253
+ if data_management_enabled is not None:
254
+ filters.append(
255
+ f"DataManagementEnabled eq {str(data_management_enabled).lower()}"
256
+ )
257
+
258
+ if is_read_only is not None:
259
+ filters.append(f"IsReadOnly eq {str(is_read_only).lower()}")
260
+
261
+ options = QueryOptions()
262
+ if filters:
263
+ options.filter = " and ".join(filters)
264
+
265
+ data = await self.get_data_entities(options)
266
+
267
+ entities = []
268
+ for item in data.get("value", []):
269
+ entity = DataEntityInfo(
270
+ name=item.get("Name", ""),
271
+ public_entity_name=item.get("PublicEntityName", ""),
272
+ public_collection_name=item.get("PublicCollectionName", ""),
273
+ label_id=item.get("LabelId"),
274
+ data_service_enabled=item.get("DataServiceEnabled", False),
275
+ data_management_enabled=item.get("DataManagementEnabled", False),
276
+ entity_category=item.get("EntityCategory"),
277
+ is_read_only=item.get("IsReadOnly", False),
278
+ )
279
+ entities.append(entity)
280
+
281
+ # Apply regex pattern matching if provided
282
+ if pattern and re.search(r"[.*+?^${}()|[\]\\]", pattern):
283
+ flags = re.IGNORECASE
284
+ entities = [e for e in entities if re.search(pattern, e.name, flags)]
285
+
286
+ return entities
287
+
288
+ async def get_data_entity_info(
289
+ self, entity_name: str, resolve_labels: bool = True, language: str = "en-US"
290
+ ) -> Optional[DataEntityInfo]:
291
+ """Get detailed information about a specific data entity
292
+
293
+ Args:
294
+ entity_name: Name of the data entity
295
+ resolve_labels: Whether to resolve label IDs to text
296
+ language: Language for label resolution
297
+
298
+ Returns:
299
+ DataEntityInfo object or None if not found
300
+ """
301
+ try:
302
+ session = await self.session_manager.get_session()
303
+ url = f"{self.metadata_url}/DataEntities('{entity_name}')"
304
+
305
+ async with session.get(url) as response:
306
+ if response.status == 200:
307
+ item = await response.json()
308
+ entity = DataEntityInfo(
309
+ name=item.get("Name", ""),
310
+ public_entity_name=item.get("PublicEntityName", ""),
311
+ public_collection_name=item.get("PublicCollectionName", ""),
312
+ label_id=item.get("LabelId"),
313
+ data_service_enabled=item.get("DataServiceEnabled", False),
314
+ data_management_enabled=item.get(
315
+ "DataManagementEnabled", False
316
+ ),
317
+ entity_category=item.get("EntityCategory"),
318
+ is_read_only=item.get("IsReadOnly", False),
319
+ )
320
+
321
+ # Resolve labels if requested
322
+ if resolve_labels and self.label_ops and entity.label_id:
323
+ entity.label_text = await self.label_ops.get_label_text(
324
+ entity.label_id, language
325
+ )
326
+
327
+ return entity
328
+ elif response.status == 404:
329
+ return None
330
+ else:
331
+ raise Exception(
332
+ f"Failed to get data entity: {response.status} - {await response.text()}"
333
+ )
334
+
335
+ except Exception as e:
336
+ raise Exception(f"Error getting data entity '{entity_name}': {e}")
337
+
338
+ # PublicEntities endpoint operations
339
+
340
+ async def get_public_entities(
341
+ self, options: Optional[QueryOptions] = None
342
+ ) -> Dict[str, Any]:
343
+ """Get public entities from PublicEntities endpoint
344
+
345
+ Args:
346
+ options: OData query options
347
+
348
+ Returns:
349
+ Response containing public entities
350
+ """
351
+ session = await self.session_manager.get_session()
352
+ url = f"{self.metadata_url}/PublicEntities"
353
+
354
+ params = QueryBuilder.build_query_params(options)
355
+
356
+ async with session.get(url, params=params) as response:
357
+ if response.status == 200:
358
+ return await response.json()
359
+ else:
360
+ raise Exception(
361
+ f"Failed to get public entities: {response.status} - {await response.text()}"
362
+ )
363
+
364
+ async def get_all_public_entities_with_details(
365
+ self, resolve_labels: bool = False, language: str = "en-US"
366
+ ) -> List[PublicEntityInfo]:
367
+ """Get all public entities with full details in a single optimized call
368
+
369
+ This method uses the fact that PublicEntities endpoint returns complete entity data,
370
+ avoiding the need for individual calls to PublicEntities('EntityName').
371
+
372
+ Args:
373
+ resolve_labels: Whether to resolve label IDs to text
374
+ language: Language for label resolution
375
+
376
+ Returns:
377
+ List of PublicEntityInfo objects with complete details
378
+ """
379
+ # Get all public entities with full details
380
+ entities_data = await self.get_public_entities()
381
+ entities = []
382
+
383
+ for item in entities_data.get("value", []):
384
+ try:
385
+ # Parse entity using utility function
386
+ entity = self._parse_public_entity_from_json(item)
387
+
388
+ # Resolve labels if requested
389
+ if resolve_labels and self.label_ops:
390
+ await self._resolve_public_entity_labels(entity, language)
391
+
392
+ entities.append(entity)
393
+
394
+ except Exception as e:
395
+ # Log error but continue processing other entities
396
+ logger.warning(
397
+ f"Failed to parse entity {item.get('Name', 'unknown')}: {e}"
398
+ )
399
+ continue
400
+
401
+ return entities
402
+
403
+ async def search_public_entities(
404
+ self,
405
+ pattern: str = "",
406
+ is_read_only: Optional[bool] = None,
407
+ configuration_enabled: Optional[bool] = None,
408
+ ) -> List[PublicEntityInfo]:
409
+ """Search public entities with filtering
410
+
411
+ Args:
412
+ pattern: Search pattern for entity name (regex supported)
413
+ is_read_only: Filter by read-only status
414
+ configuration_enabled: Filter by configuration enabled status
415
+
416
+ Returns:
417
+ List of matching public entities (without detailed properties)
418
+ """
419
+ # Build OData filter
420
+ filters = []
421
+
422
+ if pattern:
423
+ # Use contains for pattern matching
424
+ filters.append(f"contains(tolower(Name), '{pattern.lower()}')")
425
+
426
+ if is_read_only is not None:
427
+ filters.append(f"IsReadOnly eq {str(is_read_only).lower()}")
428
+
429
+ if configuration_enabled is not None:
430
+ filters.append(
431
+ f"ConfigurationEnabled eq {str(configuration_enabled).lower()}"
432
+ )
433
+
434
+ options = QueryOptions()
435
+ if filters:
436
+ options.filter = " and ".join(filters)
437
+
438
+ # Only select basic fields for search to improve performance
439
+ options.select = [
440
+ "Name",
441
+ "EntitySetName",
442
+ "LabelId",
443
+ "IsReadOnly",
444
+ "ConfigurationEnabled",
445
+ ]
446
+
447
+ data = await self.get_public_entities(options)
448
+
449
+ entities = []
450
+ for item in data.get("value", []):
451
+ entity = PublicEntityInfo(
452
+ name=item.get("Name", ""),
453
+ entity_set_name=item.get("EntitySetName", ""),
454
+ label_id=item.get("LabelId"),
455
+ is_read_only=item.get("IsReadOnly", False),
456
+ configuration_enabled=item.get("ConfigurationEnabled", True),
457
+ )
458
+ entities.append(entity)
459
+
460
+ # Apply regex pattern matching if provided
461
+ if pattern and re.search(r"[.*+?^${}()|[\]\\]", pattern):
462
+ flags = re.IGNORECASE
463
+ entities = [e for e in entities if re.search(pattern, e.name, flags)]
464
+
465
+ return entities
466
+
467
+ async def get_public_entity_info(
468
+ self, entity_name: str, resolve_labels: bool = True, language: str = "en-US"
469
+ ) -> Optional[PublicEntityInfo]:
470
+ """Get detailed information about a specific public entity
471
+
472
+ Args:
473
+ entity_name: Name of the public entity
474
+ resolve_labels: Whether to resolve label IDs to text
475
+ language: Language for label resolution
476
+
477
+ Returns:
478
+ PublicEntityInfo object with full details or None if not found
479
+ """
480
+ try:
481
+ session = await self.session_manager.get_session()
482
+ url = f"{self.metadata_url}/PublicEntities('{entity_name}')"
483
+
484
+ async with session.get(url) as response:
485
+ if response.status == 200:
486
+ item = await response.json()
487
+
488
+ # Use utility function to parse the entity
489
+ entity = self._parse_public_entity_from_json(item)
490
+
491
+ # Resolve labels if requested
492
+ if resolve_labels and self.label_ops:
493
+ await self._resolve_public_entity_labels(entity, language)
494
+
495
+ return entity
496
+ elif response.status == 404:
497
+ return None
498
+ else:
499
+ raise Exception(
500
+ f"Failed to get public entity: {response.status} - {await response.text()}"
501
+ )
502
+
503
+ except Exception as e:
504
+ raise Exception(f"Error getting public entity '{entity_name}': {e}")
505
+
506
+ # PublicEnumerations endpoint operations
507
+
508
+ async def get_public_enumerations(
509
+ self, options: Optional[QueryOptions] = None
510
+ ) -> Dict[str, Any]:
511
+ """Get public enumerations from PublicEnumerations endpoint
512
+
513
+ Args:
514
+ options: OData query options
515
+
516
+ Returns:
517
+ Response containing public enumerations
518
+ """
519
+ session = await self.session_manager.get_session()
520
+ url = f"{self.metadata_url}/PublicEnumerations"
521
+
522
+ params = QueryBuilder.build_query_params(options)
523
+
524
+ async with session.get(url, params=params) as response:
525
+ if response.status == 200:
526
+ return await response.json()
527
+ else:
528
+ raise Exception(
529
+ f"Failed to get public enumerations: {response.status} - {await response.text()}"
530
+ )
531
+
532
+ async def get_all_public_enumerations_with_details(
533
+ self, resolve_labels: bool = False, language: str = "en-US"
534
+ ) -> List[EnumerationInfo]:
535
+ """Get all public enumerations with full details in a single optimized call
536
+
537
+ This method uses the fact that PublicEnumerations endpoint returns complete enumeration data,
538
+ avoiding the need for individual calls to PublicEnumerations('EnumName').
539
+
540
+ Args:
541
+ resolve_labels: Whether to resolve label IDs to text
542
+ language: Language for label resolution
543
+
544
+ Returns:
545
+ List of EnumerationInfo objects with complete details
546
+ """
547
+ # Get all public enumerations with full details
548
+ enums_data = await self.get_public_enumerations()
549
+ enumerations = []
550
+
551
+ for item in enums_data.get("value", []):
552
+ try:
553
+ # Parse enumeration using utility function
554
+ enum = self._parse_public_enumeration_from_json(item)
555
+
556
+ # Resolve labels if requested
557
+ if resolve_labels and self.label_ops:
558
+ await self._resolve_enumeration_labels(enum, language)
559
+
560
+ enumerations.append(enum)
561
+
562
+ except Exception as e:
563
+ # Log error but continue processing other enumerations
564
+ logger.warning(
565
+ f"Failed to parse enumeration {item.get('Name', 'unknown')}: {e}"
566
+ )
567
+ continue
568
+
569
+ return enumerations
570
+
571
+ async def search_public_enumerations(
572
+ self, pattern: str = ""
573
+ ) -> List[EnumerationInfo]:
574
+ """Search public enumerations with filtering
575
+
576
+ Args:
577
+ pattern: Search pattern for enumeration name (regex supported)
578
+
579
+ Returns:
580
+ List of matching enumerations (without detailed members)
581
+ """
582
+ # Build OData filter
583
+ options = QueryOptions()
584
+ if pattern:
585
+ options.filter = f"contains(tolower(Name), '{pattern.lower()}')"
586
+
587
+ # Only select basic fields for search to improve performance
588
+ options.select = ["Name", "LabelId"]
589
+
590
+ data = await self.get_public_enumerations(options)
591
+
592
+ enumerations = []
593
+ for item in data.get("value", []):
594
+ enum = EnumerationInfo(
595
+ name=item.get("Name", ""), label_id=item.get("LabelId")
596
+ )
597
+ enumerations.append(enum)
598
+
599
+ # Apply regex pattern matching if provided
600
+ if pattern and re.search(r"[.*+?^${}()|[\]\\]", pattern):
601
+ flags = re.IGNORECASE
602
+ enumerations = [
603
+ e for e in enumerations if re.search(pattern, e.name, flags)
604
+ ]
605
+
606
+ return enumerations
607
+
608
+ async def get_public_enumeration_info(
609
+ self,
610
+ enumeration_name: str,
611
+ resolve_labels: bool = True,
612
+ language: str = "en-US",
613
+ ) -> Optional[EnumerationInfo]:
614
+ """Get detailed information about a specific public enumeration
615
+
616
+ Args:
617
+ enumeration_name: Name of the enumeration
618
+ resolve_labels: Whether to resolve label IDs to text
619
+ language: Language for label resolution
620
+
621
+ Returns:
622
+ EnumerationInfo object with full details or None if not found
623
+ """
624
+ try:
625
+ session = await self.session_manager.get_session()
626
+ url = f"{self.metadata_url}/PublicEnumerations('{enumeration_name}')"
627
+
628
+ async with session.get(url) as response:
629
+ if response.status == 200:
630
+ item = await response.json()
631
+
632
+ # Use utility function to parse the enumeration
633
+ enum = self._parse_public_enumeration_from_json(item)
634
+
635
+ # Resolve labels if requested
636
+ if resolve_labels and self.label_ops:
637
+ await self._resolve_enumeration_labels(enum, language)
638
+
639
+ return enum
640
+ elif response.status == 404:
641
+ return None
642
+ else:
643
+ raise Exception(
644
+ f"Failed to get public enumeration: {response.status} - {await response.text()}"
645
+ )
646
+
647
+ except Exception as e:
648
+ raise Exception(
649
+ f"Error getting public enumeration '{enumeration_name}': {e}"
650
+ )
651
+
652
+ # Helper methods for label resolution
653
+
654
+ async def _resolve_public_entity_labels(
655
+ self, entity: PublicEntityInfo, language: str
656
+ ) -> None:
657
+ """Resolve labels for a public entity"""
658
+ # Collect all label IDs
659
+ label_ids = []
660
+
661
+ if entity.label_id:
662
+ label_ids.append(entity.label_id)
663
+
664
+ for prop in entity.properties:
665
+ if prop.label_id:
666
+ label_ids.append(prop.label_id)
667
+
668
+ # Resolve labels in batch
669
+ if label_ids:
670
+ labels = await self.label_ops.get_labels_batch(label_ids, language)
671
+
672
+ # Apply resolved labels
673
+ if entity.label_id:
674
+ entity.label_text = labels.get(entity.label_id)
675
+
676
+ for prop in entity.properties:
677
+ if prop.label_id:
678
+ prop.label_text = labels.get(prop.label_id)
679
+
680
+ async def _resolve_enumeration_labels(
681
+ self, enum: EnumerationInfo, language: str
682
+ ) -> None:
683
+ """Resolve labels for an enumeration"""
684
+ # Collect all label IDs
685
+ label_ids = []
686
+
687
+ if enum.label_id:
688
+ label_ids.append(enum.label_id)
689
+
690
+ for member in enum.members:
691
+ if member.label_id:
692
+ label_ids.append(member.label_id)
693
+
694
+ # Resolve labels in batch
695
+ if label_ids:
696
+ labels = await self.label_ops.get_labels_batch(label_ids, language)
697
+
698
+ # Apply resolved labels
699
+ if enum.label_id:
700
+ enum.label_text = labels.get(enum.label_id)
701
+
702
+ for member in enum.members:
703
+ if member.label_id:
704
+ member.label_text = labels.get(member.label_id)
705
+
706
+ # Version Information Methods
707
+
708
+ async def get_application_version(self) -> str:
709
+ """Get the current application version of the D365 F&O environment
710
+
711
+ Returns:
712
+ str: The application version string
713
+
714
+ Raises:
715
+ Exception: If the action call fails
716
+ """
717
+ try:
718
+ result = await self.crud_ops.call_action(
719
+ "GetApplicationVersion", {}, "DataManagementEntities", None
720
+ )
721
+ # The action returns a simple string value
722
+ if isinstance(result, str):
723
+ return result
724
+ elif isinstance(result, dict) and "value" in result:
725
+ return str(result["value"])
726
+ else:
727
+ return str(result) if result is not None else ""
728
+
729
+ except Exception as e:
730
+ logger.error(f"Failed to get application version: {e}")
731
+ raise
732
+
733
+ async def get_platform_build_version(self) -> str:
734
+ """Get the current platform build version of the D365 F&O environment
735
+
736
+ Returns:
737
+ str: The platform build version string
738
+
739
+ Raises:
740
+ Exception: If the action call fails
741
+ """
742
+ try:
743
+ result = await self.crud_ops.call_action(
744
+ "GetPlatformBuildVersion", {}, "DataManagementEntities", None
745
+ )
746
+ # The action returns a simple string value
747
+ if isinstance(result, str):
748
+ return result
749
+ elif isinstance(result, dict) and "value" in result:
750
+ return str(result["value"])
751
+ else:
752
+ return str(result) if result is not None else ""
753
+
754
+ except Exception as e:
755
+ logger.error(f"Failed to get platform build version: {e}")
756
+ raise
757
+
758
+ async def get_installed_modules(self) -> List[str]:
759
+ """Get the list of installed modules in the D365 F&O environment
760
+
761
+ Returns:
762
+ List[str]: List of module strings in format:
763
+ "Name: {name} | Version: {version} | Module: {module_id} | Publisher: {publisher} | DisplayName: {display_name}"
764
+
765
+ Raises:
766
+ Exception: If the action call fails
767
+ """
768
+ try:
769
+ result = await self.crud_ops.call_action(
770
+ "GetInstalledModules", {}, "SystemNotifications", None
771
+ )
772
+
773
+ # The action returns a list of module strings
774
+ if isinstance(result, list):
775
+ return result
776
+ elif isinstance(result, dict) and "value" in result:
777
+ modules = result["value"]
778
+ if isinstance(modules, list):
779
+ return modules
780
+ else:
781
+ logger.warning(
782
+ f"GetInstalledModules returned unexpected value format: {type(modules)}"
783
+ )
784
+ return []
785
+ else:
786
+ logger.warning(
787
+ f"GetInstalledModules returned unexpected format: {type(result)}"
788
+ )
789
+ return []
790
+
791
+ except Exception as e:
792
+ logger.error(f"Failed to get installed modules: {e}")
793
+ raise