d365fo-client 0.1.0__py3-none-any.whl → 0.2.1__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/client.py +58 -32
- d365fo_client/config.py +8 -0
- d365fo_client/labels.py +3 -3
- d365fo_client/main.py +6 -6
- d365fo_client/mcp/client_manager.py +30 -2
- d365fo_client/mcp/main.py +20 -7
- d365fo_client/mcp/server.py +90 -5
- d365fo_client/mcp/tools/profile_tools.py +15 -0
- d365fo_client/metadata_api.py +223 -6
- d365fo_client/metadata_v2/cache_v2.py +253 -88
- d365fo_client/metadata_v2/database_v2.py +0 -2
- d365fo_client/metadata_v2/label_utils.py +107 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +83 -9
- d365fo_client/models.py +2 -2
- d365fo_client/output.py +4 -4
- d365fo_client/profile_manager.py +12 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.1.dist-info}/METADATA +5 -1
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.1.dist-info}/RECORD +22 -21
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.1.dist-info}/WHEEL +0 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.1.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.1.dist-info}/top_level.txt +0 -0
d365fo_client/metadata_api.py
CHANGED
@@ -8,19 +8,25 @@ from d365fo_client.crud import CrudOperations
|
|
8
8
|
|
9
9
|
from .labels import LabelOperations
|
10
10
|
from .models import (
|
11
|
+
ActionInfo,
|
11
12
|
ActionParameterInfo,
|
12
13
|
ActionParameterTypeInfo,
|
13
14
|
ActionReturnTypeInfo,
|
15
|
+
Cardinality,
|
14
16
|
DataEntityInfo,
|
17
|
+
EntityCategory,
|
15
18
|
EnumerationInfo,
|
16
19
|
EnumerationMemberInfo,
|
20
|
+
FixedConstraintInfo,
|
17
21
|
NavigationPropertyInfo,
|
22
|
+
ODataBindingKind,
|
18
23
|
PropertyGroupInfo,
|
19
24
|
PublicEntityActionInfo,
|
20
25
|
PublicEntityInfo,
|
21
26
|
PublicEntityPropertyInfo,
|
22
27
|
QueryOptions,
|
23
28
|
ReferentialConstraintInfo,
|
29
|
+
RelatedFixedConstraintInfo,
|
24
30
|
)
|
25
31
|
from .query import QueryBuilder
|
26
32
|
from .session import SessionManager
|
@@ -97,26 +103,57 @@ class MetadataAPIOperations:
|
|
97
103
|
|
98
104
|
# Process navigation properties
|
99
105
|
for nav_data in item.get("NavigationProperties", []):
|
106
|
+
# Convert cardinality string to enum
|
107
|
+
cardinality_str = nav_data.get("Cardinality", "Single")
|
108
|
+
try:
|
109
|
+
if cardinality_str == "Single":
|
110
|
+
cardinality = Cardinality.SINGLE
|
111
|
+
elif cardinality_str == "Multiple":
|
112
|
+
cardinality = Cardinality.MULTIPLE
|
113
|
+
else:
|
114
|
+
cardinality = Cardinality.SINGLE # Default
|
115
|
+
except Exception:
|
116
|
+
cardinality = Cardinality.SINGLE
|
117
|
+
|
100
118
|
nav_prop = NavigationPropertyInfo(
|
101
119
|
name=nav_data.get("Name", ""),
|
102
120
|
related_entity=nav_data.get("RelatedEntity", ""),
|
103
121
|
related_relation_name=nav_data.get("RelatedRelationName"),
|
104
|
-
cardinality=
|
122
|
+
cardinality=cardinality,
|
105
123
|
)
|
106
124
|
|
107
125
|
# Process constraints
|
108
126
|
for constraint_data in nav_data.get("Constraints", []):
|
109
|
-
# Check for ReferentialConstraint type (most common)
|
110
127
|
odata_type = constraint_data.get("@odata.type", "")
|
128
|
+
|
111
129
|
if "ReferentialConstraint" in odata_type:
|
130
|
+
# Referential constraint (foreign key relationship)
|
112
131
|
constraint = ReferentialConstraintInfo(
|
113
|
-
constraint_type="Referential",
|
114
132
|
property=constraint_data.get("Property", ""),
|
115
133
|
referenced_property=constraint_data.get(
|
116
134
|
"ReferencedProperty", ""
|
117
135
|
),
|
118
136
|
)
|
119
137
|
nav_prop.constraints.append(constraint)
|
138
|
+
|
139
|
+
elif "RelatedFixedConstraint" in odata_type:
|
140
|
+
# Related fixed constraint (check this before FixedConstraint)
|
141
|
+
constraint = RelatedFixedConstraintInfo(
|
142
|
+
related_property=constraint_data.get("RelatedProperty", ""),
|
143
|
+
value=constraint_data.get("Value"),
|
144
|
+
value_str=constraint_data.get("ValueStr", constraint_data.get("StringValue")),
|
145
|
+
)
|
146
|
+
nav_prop.constraints.append(constraint)
|
147
|
+
|
148
|
+
elif "FixedConstraint" in odata_type:
|
149
|
+
# Fixed value constraint
|
150
|
+
constraint = FixedConstraintInfo(
|
151
|
+
property=constraint_data.get("Property", ""),
|
152
|
+
value=constraint_data.get("Value"),
|
153
|
+
value_str=constraint_data.get("ValueStr", constraint_data.get("StringValue")),
|
154
|
+
)
|
155
|
+
nav_prop.constraints.append(constraint)
|
156
|
+
nav_prop.constraints.append(constraint)
|
120
157
|
|
121
158
|
entity.navigation_properties.append(nav_prop)
|
122
159
|
|
@@ -130,9 +167,25 @@ class MetadataAPIOperations:
|
|
130
167
|
|
131
168
|
# Process actions
|
132
169
|
for action_data in item.get("Actions", []):
|
170
|
+
# Convert binding kind string to enum
|
171
|
+
binding_kind_str = action_data.get("BindingKind", "BoundToEntitySet")
|
172
|
+
try:
|
173
|
+
# Try to map the string to the enum
|
174
|
+
if binding_kind_str == "BoundToEntityInstance":
|
175
|
+
binding_kind = ODataBindingKind.BOUND_TO_ENTITY_INSTANCE
|
176
|
+
elif binding_kind_str == "BoundToEntitySet":
|
177
|
+
binding_kind = ODataBindingKind.BOUND_TO_ENTITY_SET
|
178
|
+
elif binding_kind_str == "Unbound":
|
179
|
+
binding_kind = ODataBindingKind.UNBOUND
|
180
|
+
else:
|
181
|
+
# Default to BoundToEntitySet if unknown
|
182
|
+
binding_kind = ODataBindingKind.BOUND_TO_ENTITY_SET
|
183
|
+
except Exception:
|
184
|
+
binding_kind = ODataBindingKind.BOUND_TO_ENTITY_SET
|
185
|
+
|
133
186
|
action = PublicEntityActionInfo(
|
134
187
|
name=action_data.get("Name", ""),
|
135
|
-
binding_kind=
|
188
|
+
binding_kind=binding_kind,
|
136
189
|
field_lookup=action_data.get("FieldLookup"),
|
137
190
|
)
|
138
191
|
|
@@ -266,6 +319,28 @@ class MetadataAPIOperations:
|
|
266
319
|
|
267
320
|
entities = []
|
268
321
|
for item in data.get("value", []):
|
322
|
+
# Convert entity category string to enum
|
323
|
+
entity_category_str = item.get("EntityCategory")
|
324
|
+
entity_category = None
|
325
|
+
if entity_category_str:
|
326
|
+
try:
|
327
|
+
# Try to map the string to the enum
|
328
|
+
if entity_category_str == "Master":
|
329
|
+
entity_category = EntityCategory.MASTER
|
330
|
+
elif entity_category_str == "Transaction":
|
331
|
+
entity_category = EntityCategory.TRANSACTION
|
332
|
+
elif entity_category_str == "Configuration":
|
333
|
+
entity_category = EntityCategory.CONFIGURATION
|
334
|
+
elif entity_category_str == "Reference":
|
335
|
+
entity_category = EntityCategory.REFERENCE
|
336
|
+
elif entity_category_str == "Document":
|
337
|
+
entity_category = EntityCategory.DOCUMENT
|
338
|
+
elif entity_category_str == "Parameters":
|
339
|
+
entity_category = EntityCategory.PARAMETERS
|
340
|
+
# If no match, leave as None
|
341
|
+
except Exception:
|
342
|
+
entity_category = None
|
343
|
+
|
269
344
|
entity = DataEntityInfo(
|
270
345
|
name=item.get("Name", ""),
|
271
346
|
public_entity_name=item.get("PublicEntityName", ""),
|
@@ -273,7 +348,7 @@ class MetadataAPIOperations:
|
|
273
348
|
label_id=item.get("LabelId"),
|
274
349
|
data_service_enabled=item.get("DataServiceEnabled", False),
|
275
350
|
data_management_enabled=item.get("DataManagementEnabled", False),
|
276
|
-
entity_category=
|
351
|
+
entity_category=entity_category,
|
277
352
|
is_read_only=item.get("IsReadOnly", False),
|
278
353
|
)
|
279
354
|
entities.append(entity)
|
@@ -305,6 +380,29 @@ class MetadataAPIOperations:
|
|
305
380
|
async with session.get(url) as response:
|
306
381
|
if response.status == 200:
|
307
382
|
item = await response.json()
|
383
|
+
|
384
|
+
# Convert entity category string to enum
|
385
|
+
entity_category_str = item.get("EntityCategory")
|
386
|
+
entity_category = None
|
387
|
+
if entity_category_str:
|
388
|
+
try:
|
389
|
+
# Try to map the string to the enum
|
390
|
+
if entity_category_str == "Master":
|
391
|
+
entity_category = EntityCategory.MASTER
|
392
|
+
elif entity_category_str == "Transaction":
|
393
|
+
entity_category = EntityCategory.TRANSACTION
|
394
|
+
elif entity_category_str == "Configuration":
|
395
|
+
entity_category = EntityCategory.CONFIGURATION
|
396
|
+
elif entity_category_str == "Reference":
|
397
|
+
entity_category = EntityCategory.REFERENCE
|
398
|
+
elif entity_category_str == "Document":
|
399
|
+
entity_category = EntityCategory.DOCUMENT
|
400
|
+
elif entity_category_str == "Parameters":
|
401
|
+
entity_category = EntityCategory.PARAMETERS
|
402
|
+
# If no match, leave as None
|
403
|
+
except Exception:
|
404
|
+
entity_category = None
|
405
|
+
|
308
406
|
entity = DataEntityInfo(
|
309
407
|
name=item.get("Name", ""),
|
310
408
|
public_entity_name=item.get("PublicEntityName", ""),
|
@@ -314,7 +412,7 @@ class MetadataAPIOperations:
|
|
314
412
|
data_management_enabled=item.get(
|
315
413
|
"DataManagementEnabled", False
|
316
414
|
),
|
317
|
-
entity_category=
|
415
|
+
entity_category=entity_category,
|
318
416
|
is_read_only=item.get("IsReadOnly", False),
|
319
417
|
)
|
320
418
|
|
@@ -791,3 +889,122 @@ class MetadataAPIOperations:
|
|
791
889
|
except Exception as e:
|
792
890
|
logger.error(f"Failed to get installed modules: {e}")
|
793
891
|
raise
|
892
|
+
|
893
|
+
# Action Operations
|
894
|
+
|
895
|
+
async def search_actions(
|
896
|
+
self,
|
897
|
+
pattern: str = "",
|
898
|
+
entity_name: Optional[str] = None,
|
899
|
+
binding_kind: Optional[str] = None,
|
900
|
+
) -> List["ActionInfo"]:
|
901
|
+
"""Search actions across all public entities
|
902
|
+
|
903
|
+
Args:
|
904
|
+
pattern: Search pattern for action name (regex supported)
|
905
|
+
entity_name: Filter actions that are bound to a specific entity
|
906
|
+
binding_kind: Filter by binding type (Unbound, BoundToEntitySet, BoundToEntityInstance)
|
907
|
+
|
908
|
+
Returns:
|
909
|
+
List of ActionInfo objects with full details
|
910
|
+
"""
|
911
|
+
from .models import ActionInfo
|
912
|
+
|
913
|
+
actions = []
|
914
|
+
|
915
|
+
try:
|
916
|
+
# Compile regex pattern if provided
|
917
|
+
regex_pattern = None
|
918
|
+
if pattern:
|
919
|
+
try:
|
920
|
+
regex_pattern = re.compile(pattern, re.IGNORECASE)
|
921
|
+
except re.error:
|
922
|
+
# If regex is invalid, treat as literal string
|
923
|
+
pattern_lower = pattern.lower()
|
924
|
+
regex_pattern = lambda x: pattern_lower in x.lower()
|
925
|
+
else:
|
926
|
+
regex_pattern = regex_pattern.search
|
927
|
+
|
928
|
+
# Get all public entities with full details
|
929
|
+
entities = await self.get_all_public_entities_with_details(resolve_labels=False)
|
930
|
+
|
931
|
+
for entity in entities:
|
932
|
+
# Filter by entity name if specified
|
933
|
+
if entity_name and entity.name != entity_name:
|
934
|
+
continue
|
935
|
+
|
936
|
+
# Process all actions in this entity
|
937
|
+
for action in entity.actions:
|
938
|
+
# Filter by action name pattern
|
939
|
+
if regex_pattern and not regex_pattern(action.name):
|
940
|
+
continue
|
941
|
+
|
942
|
+
# Filter by binding kind
|
943
|
+
if binding_kind and action.binding_kind.value != binding_kind:
|
944
|
+
continue
|
945
|
+
|
946
|
+
# Create ActionInfo with entity context
|
947
|
+
action_info = ActionInfo(
|
948
|
+
name=action.name,
|
949
|
+
binding_kind=action.binding_kind,
|
950
|
+
entity_name=entity.name, # public entity name
|
951
|
+
entity_set_name=entity.entity_set_name, # entity set name for OData URLs
|
952
|
+
parameters=action.parameters,
|
953
|
+
return_type=action.return_type,
|
954
|
+
field_lookup=action.field_lookup,
|
955
|
+
)
|
956
|
+
actions.append(action_info)
|
957
|
+
|
958
|
+
except Exception as e:
|
959
|
+
logger.error(f"Failed to search actions: {e}")
|
960
|
+
# Return empty list on error rather than raising
|
961
|
+
return []
|
962
|
+
|
963
|
+
return actions
|
964
|
+
|
965
|
+
async def get_action_info(
|
966
|
+
self,
|
967
|
+
action_name: str,
|
968
|
+
entity_name: Optional[str] = None,
|
969
|
+
) -> Optional["ActionInfo"]:
|
970
|
+
"""Get detailed information about a specific action
|
971
|
+
|
972
|
+
Args:
|
973
|
+
action_name: Name of the action
|
974
|
+
entity_name: Optional entity name for bound actions
|
975
|
+
|
976
|
+
Returns:
|
977
|
+
ActionInfo object or None if not found
|
978
|
+
"""
|
979
|
+
from .models import ActionInfo
|
980
|
+
|
981
|
+
try:
|
982
|
+
if entity_name:
|
983
|
+
# If entity name is provided, get that specific entity
|
984
|
+
entity = await self.get_public_entity_info(entity_name, resolve_labels=False)
|
985
|
+
if not entity:
|
986
|
+
return None
|
987
|
+
|
988
|
+
# Find the action in this entity
|
989
|
+
for action in entity.actions:
|
990
|
+
if action.name == action_name:
|
991
|
+
return ActionInfo(
|
992
|
+
name=action.name,
|
993
|
+
binding_kind=action.binding_kind,
|
994
|
+
entity_name=entity.name,
|
995
|
+
entity_set_name=entity.entity_set_name,
|
996
|
+
parameters=action.parameters,
|
997
|
+
return_type=action.return_type,
|
998
|
+
field_lookup=action.field_lookup,
|
999
|
+
)
|
1000
|
+
else:
|
1001
|
+
# Search across all entities for the action
|
1002
|
+
actions = await self.search_actions(pattern=f"^{re.escape(action_name)}$")
|
1003
|
+
if actions:
|
1004
|
+
# Return the first match (actions should be unique across entities)
|
1005
|
+
return actions[0]
|
1006
|
+
|
1007
|
+
except Exception as e:
|
1008
|
+
logger.error(f"Failed to get action info for '{action_name}': {e}")
|
1009
|
+
|
1010
|
+
return None
|