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,528 @@
1
+ """Label operations for D365 F&O client."""
2
+
3
+ import logging
4
+ from typing import Any, Dict, List, Optional, Protocol, Union, runtime_checkable
5
+
6
+ from .models import LabelInfo, PublicEntityInfo
7
+ from .session import SessionManager
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @runtime_checkable
13
+ class LabelCacheProtocol(Protocol):
14
+ """Protocol for label caching implementations."""
15
+
16
+ async def get_label(self, label_id: str, language: str) -> Optional[str]:
17
+ """Get a single label from cache."""
18
+ ...
19
+
20
+ async def set_label(self, label_info: LabelInfo) -> None:
21
+ """Set a single label in cache."""
22
+ ...
23
+
24
+ async def set_labels_batch(self, labels: List[LabelInfo]) -> None:
25
+ """Set multiple labels in cache."""
26
+ ...
27
+
28
+ async def get_labels_batch(
29
+ self, label_ids: List[str], language: str
30
+ ) -> Dict[str, str]:
31
+ """Resolve multiple label IDs to their text values."""
32
+ ...
33
+
34
+
35
+ class LabelOperations:
36
+ """Handles label operations for F&O client"""
37
+
38
+ def __init__(
39
+ self,
40
+ session_manager: SessionManager,
41
+ metadata_url: str,
42
+ label_cache: Optional[LabelCacheProtocol] = None,
43
+ ):
44
+ """Initialize label operations
45
+
46
+ Args:
47
+ session_manager: HTTP session manager
48
+ metadata_url: Metadata API URL
49
+ label_cache: Optional label cache implementing LabelCacheProtocol
50
+ """
51
+ self.session_manager = session_manager
52
+ self.metadata_url = metadata_url
53
+ self.label_cache = label_cache
54
+
55
+ def set_label_cache(self, label_cache: LabelCacheProtocol):
56
+ """Set the label cache for label operations
57
+
58
+ Args:
59
+ label_cache: Label cache implementing LabelCacheProtocol
60
+ """
61
+ self.label_cache = label_cache
62
+
63
+ async def get_label_text(
64
+ self, label_id: str, language: str = "en-US"
65
+ ) -> Optional[str]:
66
+ """Get actual label text for a specific label ID
67
+
68
+ Args:
69
+ label_id: Label ID (e.g., "@SYS13342")
70
+ language: Language code (e.g., "en-US")
71
+
72
+ Returns:
73
+ Label text or None if not found
74
+ """
75
+ # Check cache first
76
+ if self.label_cache:
77
+ cached_value = await self.label_cache.get_label(label_id, language)
78
+ if cached_value is not None:
79
+ return cached_value
80
+
81
+ try:
82
+ session = await self.session_manager.get_session()
83
+ url = f"{self.metadata_url}/Labels(Id='{label_id}',Language='{language}')"
84
+
85
+ async with session.get(url) as response:
86
+ if response.status == 200:
87
+ data = await response.json()
88
+ label_text = data.get("Value", "")
89
+
90
+ # Cache the result
91
+ if self.label_cache:
92
+ label_info = LabelInfo(
93
+ id=label_id, language=language, value=label_text
94
+ )
95
+ await self.label_cache.set_label(label_info)
96
+
97
+ return label_text
98
+ else:
99
+ print(f"Error fetching label {label_id}: {response.status}")
100
+
101
+ except Exception as e:
102
+ print(f"Exception fetching label {label_id}: {e}")
103
+
104
+ return None
105
+
106
+ async def get_labels_batch(
107
+ self, label_ids: List[str], language: str = "en-US"
108
+ ) -> Dict[str, str]:
109
+ """Get multiple labels efficiently using batch operations
110
+
111
+ Args:
112
+ label_ids: List of label IDs
113
+ language: Language code
114
+
115
+ Returns:
116
+ Dictionary mapping label ID to label text
117
+ """
118
+ if not label_ids:
119
+ return {}
120
+
121
+ results = {}
122
+ uncached_ids = []
123
+
124
+ # First, check cache for all labels if available
125
+ if self.label_cache:
126
+ for label_id in label_ids:
127
+ cached_value = await self.label_cache.get_label(label_id, language)
128
+ if cached_value is not None:
129
+ results[label_id] = cached_value
130
+ else:
131
+ uncached_ids.append(label_id)
132
+ else:
133
+ uncached_ids = label_ids
134
+
135
+ # Fetch uncached labels from API
136
+ if uncached_ids:
137
+ # For now, use individual calls - could be optimized with batch API if available
138
+ fetched_labels = []
139
+ for label_id in uncached_ids:
140
+ try:
141
+ session = await self.session_manager.get_session()
142
+ url = f"{self.metadata_url}/Labels(Id='{label_id}',Language='{language}')"
143
+
144
+ async with session.get(url) as response:
145
+ if response.status == 200:
146
+ data = await response.json()
147
+ label_text = data.get("Value", "")
148
+ results[label_id] = label_text
149
+
150
+ # Prepare for batch cache storage
151
+ fetched_labels.append(
152
+ LabelInfo(
153
+ id=label_id, language=language, value=label_text
154
+ )
155
+ )
156
+ else:
157
+ print(f"Error fetching label {label_id}: {response.status}")
158
+
159
+ except Exception as e:
160
+ print(f"Exception fetching label {label_id}: {e}")
161
+
162
+ # Batch cache all fetched labels
163
+ if fetched_labels and self.label_cache:
164
+ await self.label_cache.set_labels_batch(fetched_labels)
165
+
166
+ return results
167
+
168
+ async def resolve_public_entity_labels(
169
+ self, entity_info: PublicEntityInfo, language: str
170
+ ):
171
+ """Resolve all label IDs in a public entity to actual text
172
+
173
+ Args:
174
+ entity_info: Public entity information object to update
175
+ language: Language code
176
+ """
177
+ # Collect all label IDs
178
+ label_ids = []
179
+ if entity_info.label_id:
180
+ label_ids.append(entity_info.label_id)
181
+
182
+ for prop in entity_info.properties:
183
+ if prop.label_id:
184
+ label_ids.append(prop.label_id)
185
+
186
+ # Batch fetch all labels
187
+ if label_ids:
188
+ labels_map = await self.get_labels_batch(label_ids, language)
189
+
190
+ # Apply resolved labels
191
+ if entity_info.label_id:
192
+ entity_info.label_text = labels_map.get(entity_info.label_id)
193
+
194
+ for prop in entity_info.properties:
195
+ if prop.label_id:
196
+ prop.label_text = labels_map.get(prop.label_id)
197
+
198
+
199
+ # Generic Label Resolution Utility Functions
200
+
201
+
202
+ async def resolve_labels_generic(
203
+ obj_or_list: Union[Any, List[Any]],
204
+ label_operations: LabelOperations,
205
+ language: str = "en-US",
206
+ ) -> Union[Any, List[Any]]:
207
+ """
208
+ Generic utility function to resolve label IDs to label text for any object(s)
209
+ containing label_id and label_text properties.
210
+
211
+ Args:
212
+ obj_or_list: Single object or list of objects with label_id/label_text properties
213
+ label_operations: LabelOperations instance for label resolution
214
+ language: Language code for label resolution (default: "en-US")
215
+
216
+ Returns:
217
+ The same object(s) with label_text populated from label_id where applicable
218
+
219
+ Examples:
220
+ # Single object
221
+ entity = await resolve_labels_generic(entity, label_ops)
222
+
223
+ # List of objects
224
+ entities = await resolve_labels_generic(entities, label_ops)
225
+
226
+ # Works with any object type that has label_id/label_text attributes
227
+ properties = await resolve_labels_generic(properties, label_ops, "fr-FR")
228
+ """
229
+ logger.debug(f"Starting generic label resolution for language: {language}")
230
+
231
+ if obj_or_list is None:
232
+ logger.debug("Input is None, returning unchanged")
233
+ return obj_or_list
234
+
235
+ # Handle list of objects
236
+ if isinstance(obj_or_list, list):
237
+ if not obj_or_list:
238
+ logger.debug("Input is empty list, returning unchanged")
239
+ return obj_or_list
240
+
241
+ logger.debug(f"Processing list with {len(obj_or_list)} objects")
242
+
243
+ # Collect all label IDs from all objects in the list
244
+ label_ids = set()
245
+ for i, obj in enumerate(obj_or_list):
246
+ obj_type = type(obj).__name__ if obj else "None"
247
+ logger.debug(f"Collecting labels from list item {i} (type: {obj_type})")
248
+ _collect_label_ids_from_object(obj, label_ids)
249
+
250
+ logger.debug(
251
+ f"Collected {len(label_ids)} unique label IDs from list: {list(label_ids)}"
252
+ )
253
+
254
+ # Resolve all labels in batch
255
+ label_texts = await _resolve_labels_batch(
256
+ list(label_ids), label_operations, language
257
+ )
258
+
259
+ logger.debug(
260
+ f"Successfully resolved {len(label_texts)} labels out of {len(label_ids)} requested"
261
+ )
262
+
263
+ # Apply resolved labels to all objects
264
+ for i, obj in enumerate(obj_or_list):
265
+ logger.debug(f"Applying labels to list item {i}")
266
+ _apply_labels_to_object(obj, label_texts)
267
+
268
+ logger.debug(
269
+ f"Completed label resolution for list with {len(obj_or_list)} objects"
270
+ )
271
+ return obj_or_list
272
+
273
+ # Handle single object
274
+ else:
275
+ obj_type = type(obj_or_list).__name__
276
+ logger.debug(f"Processing single object of type: {obj_type}")
277
+
278
+ # Collect label IDs from the object
279
+ label_ids = set()
280
+ _collect_label_ids_from_object(obj_or_list, label_ids)
281
+
282
+ logger.debug(
283
+ f"Collected {len(label_ids)} label IDs from object: {list(label_ids)}"
284
+ )
285
+
286
+ # Resolve labels in batch
287
+ label_texts = await _resolve_labels_batch(
288
+ list(label_ids), label_operations, language
289
+ )
290
+
291
+ logger.debug(
292
+ f"Successfully resolved {len(label_texts)} labels out of {len(label_ids)} requested"
293
+ )
294
+
295
+ # Apply resolved labels
296
+ _apply_labels_to_object(obj_or_list, label_texts)
297
+
298
+ logger.debug(f"Completed label resolution for single {obj_type} object")
299
+ return obj_or_list
300
+
301
+
302
+ def _collect_label_ids_from_object(obj: Any, label_ids: set) -> None:
303
+ """
304
+ Recursively collect all label_id values from an object and its nested objects/lists.
305
+
306
+ Args:
307
+ obj: Object to collect label IDs from
308
+ label_ids: Set to store collected label IDs
309
+ """
310
+ if obj is None:
311
+ logger.debug("Skipping None object in label collection")
312
+ return
313
+
314
+ obj_type = type(obj).__name__
315
+ initial_count = len(label_ids)
316
+
317
+ # Check if object has label_id attribute
318
+ if hasattr(obj, "label_id") and obj.label_id:
319
+ logger.debug(f"Found label_id '{obj.label_id}' in {obj_type} object")
320
+ label_ids.add(obj.label_id)
321
+ else:
322
+ logger.debug(f"{obj_type} object has no label_id or label_id is empty")
323
+
324
+ # Recursively check common nested attributes that might contain labeled objects
325
+ nested_attrs = [
326
+ "properties",
327
+ "members",
328
+ "navigation_properties",
329
+ "property_groups",
330
+ "actions",
331
+ "parameters",
332
+ "constraints",
333
+ "enhanced_properties",
334
+ ]
335
+
336
+ for attr_name in nested_attrs:
337
+ if hasattr(obj, attr_name):
338
+ nested_obj = getattr(obj, attr_name)
339
+ if isinstance(nested_obj, list):
340
+ if nested_obj:
341
+ logger.debug(
342
+ f"Recursively collecting from {len(nested_obj)} items in {attr_name}"
343
+ )
344
+ for i, item in enumerate(nested_obj):
345
+ logger.debug(f" Processing {attr_name}[{i}]")
346
+ _collect_label_ids_from_object(item, label_ids)
347
+ else:
348
+ logger.debug(f"{attr_name} is empty list")
349
+ elif nested_obj is not None:
350
+ logger.debug(f"Recursively collecting from {attr_name} (single object)")
351
+ _collect_label_ids_from_object(nested_obj, label_ids)
352
+ else:
353
+ logger.debug(f"{attr_name} is None")
354
+
355
+ collected_count = len(label_ids) - initial_count
356
+ if collected_count > 0:
357
+ logger.debug(
358
+ f"Collected {collected_count} new label IDs from {obj_type} object"
359
+ )
360
+ else:
361
+ logger.debug(f"No new label IDs found in {obj_type} object")
362
+
363
+
364
+ async def _resolve_labels_batch(
365
+ label_ids: List[str], label_operations: LabelOperations, language: str
366
+ ) -> Dict[str, str]:
367
+ """
368
+ Resolve multiple label IDs to their text values in batch using LabelOperations.
369
+
370
+ Args:
371
+ label_ids: List of label IDs to resolve
372
+ label_operations: LabelOperations instance
373
+ language: Language code
374
+
375
+ Returns:
376
+ Dictionary mapping label_id to label_text
377
+ """
378
+ if not label_ids:
379
+ logger.debug("No label IDs to resolve")
380
+ return {}
381
+
382
+ logger.debug(
383
+ f"Starting batch resolution of {len(label_ids)} label IDs in language '{language}'"
384
+ )
385
+ logger.debug(f"Label IDs to resolve: {label_ids}")
386
+
387
+ # Use LabelOperations batch method for efficient resolution
388
+ try:
389
+ label_texts = await label_operations.get_labels_batch(label_ids, language)
390
+
391
+ successful_resolutions = len(label_texts)
392
+ failed_resolutions = len(label_ids) - successful_resolutions
393
+
394
+ logger.info(
395
+ f"Batch label resolution completed: {successful_resolutions} successful, {failed_resolutions} failed out of {len(label_ids)} total"
396
+ )
397
+
398
+ if successful_resolutions > 0:
399
+ logger.debug(f"Successfully resolved labels: {list(label_texts.keys())}")
400
+
401
+ # Log individual results at debug level
402
+ for label_id in label_ids:
403
+ if label_id in label_texts:
404
+ logger.debug(f"✅ Resolved '{label_id}' -> '{label_texts[label_id]}'")
405
+ else:
406
+ logger.debug(f"❌ No text found for label ID '{label_id}'")
407
+
408
+ return label_texts
409
+
410
+ except Exception as e:
411
+ logger.warning(f"❌ Error in batch label resolution: {e}")
412
+ return {}
413
+
414
+
415
+ def _apply_labels_to_object(obj: Any, label_texts: Dict[str, str]) -> None:
416
+ """
417
+ Recursively apply resolved labels to an object and its nested objects/lists.
418
+
419
+ Args:
420
+ obj: Object to apply labels to
421
+ label_texts: Dictionary mapping label_id to label_text
422
+ """
423
+ if obj is None:
424
+ logger.debug("Skipping None object in label application")
425
+ return
426
+
427
+ obj_type = type(obj).__name__
428
+ labels_applied = 0
429
+
430
+ # Apply label to current object if it has label_id and label_text attributes
431
+ if (
432
+ hasattr(obj, "label_id")
433
+ and hasattr(obj, "label_text")
434
+ and obj.label_id
435
+ and obj.label_id in label_texts
436
+ ):
437
+ old_text = obj.label_text
438
+ obj.label_text = label_texts[obj.label_id]
439
+ labels_applied += 1
440
+ logger.debug(
441
+ f"Applied label to {obj_type}: '{obj.label_id}' -> '{obj.label_text}' (was: {old_text})"
442
+ )
443
+ elif hasattr(obj, "label_id") and obj.label_id:
444
+ if not hasattr(obj, "label_text"):
445
+ logger.debug(
446
+ f"{obj_type} has label_id '{obj.label_id}' but no label_text attribute"
447
+ )
448
+ elif obj.label_id not in label_texts:
449
+ logger.debug(
450
+ f"{obj_type} has label_id '{obj.label_id}' but no resolved text available"
451
+ )
452
+
453
+ # Recursively apply to nested attributes
454
+ nested_attrs = [
455
+ "properties",
456
+ "members",
457
+ "navigation_properties",
458
+ "property_groups",
459
+ "actions",
460
+ "parameters",
461
+ "constraints",
462
+ "enhanced_properties",
463
+ ]
464
+
465
+ for attr_name in nested_attrs:
466
+ if hasattr(obj, attr_name):
467
+ nested_obj = getattr(obj, attr_name)
468
+ if isinstance(nested_obj, list):
469
+ if nested_obj:
470
+ logger.debug(
471
+ f"Applying labels to {len(nested_obj)} items in {attr_name}"
472
+ )
473
+ for i, item in enumerate(nested_obj):
474
+ logger.debug(f" Applying to {attr_name}[{i}]")
475
+ _apply_labels_to_object(item, label_texts)
476
+ elif nested_obj is not None:
477
+ logger.debug(f"Applying labels to {attr_name} (single object)")
478
+ _apply_labels_to_object(nested_obj, label_texts)
479
+
480
+ if labels_applied > 0:
481
+ logger.debug(f"Applied {labels_applied} labels to {obj_type} object")
482
+ else:
483
+ logger.debug(f"No labels applied to {obj_type} object")
484
+
485
+
486
+ # Utility function for resolving labels with any cache implementation
487
+ async def resolve_labels_generic_with_cache(
488
+ obj_or_list: Union[Any, List[Any]],
489
+ cache: LabelCacheProtocol,
490
+ language: str = "en-US",
491
+ ) -> Union[Any, List[Any]]:
492
+ """
493
+ Resolve labels using any cache implementation that follows LabelCacheProtocol.
494
+
495
+ Args:
496
+ obj_or_list: Single object or list of objects with label_id/label_text properties
497
+ cache: Any cache implementing LabelCacheProtocol (MetadataCache, MetadataCacheV2, etc.)
498
+ language: Language code for label resolution (default: "en-US")
499
+
500
+ Returns:
501
+ The same object(s) with label_text populated from label_id where applicable
502
+
503
+ Examples:
504
+ # Works with any cache implementation
505
+ entities = await resolve_labels_generic_with_cache(entities, metadata_cache_v2)
506
+ entities = await resolve_labels_generic_with_cache(entities, old_metadata_cache)
507
+ """
508
+
509
+ # Create a minimal LabelOperations-like resolver using the cache
510
+ class CacheLabelResolver:
511
+ def __init__(self, cache: LabelCacheProtocol):
512
+ self.cache = cache
513
+
514
+ async def get_labels_batch(
515
+ self, label_ids: List[str], language: str
516
+ ) -> Dict[str, str]:
517
+ """Get labels using the cache directly"""
518
+ label_texts = {}
519
+ for label_id in label_ids:
520
+ if label_id:
521
+ label_text = await self.cache.get_label(label_id, language)
522
+ if label_text:
523
+ label_texts[label_id] = label_text
524
+ return label_texts
525
+
526
+ # Use the generic function with the cache resolver
527
+ cache_resolver = CacheLabelResolver(cache)
528
+ return await resolve_labels_generic(obj_or_list, cache_resolver, language)