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
@@ -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
|