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,439 @@
1
+ """Module-based version detection using GetInstalledModules action."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import time
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
11
+
12
+ # Use TYPE_CHECKING to avoid circular import
13
+ if TYPE_CHECKING:
14
+ from d365fo_client.metadata_api import MetadataAPIOperations
15
+
16
+ from ..exceptions import MetadataError
17
+ from ..models import EnvironmentVersionInfo, ModuleVersionInfo, VersionDetectionResult
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class VersionDetectionError(MetadataError):
23
+ """Raised when version detection fails"""
24
+
25
+ pass
26
+
27
+
28
+ class ModuleVersionDetector:
29
+ """Detects environment version using GetInstalledModules action"""
30
+
31
+ def __init__(self, api_operations: "MetadataAPIOperations"):
32
+ """Initialize with API operations instance
33
+
34
+ Args:
35
+ api_operations: Instance providing API access (e.g., MetadataAPIOperations)
36
+ """
37
+ self.api = api_operations
38
+ self._cache_ttl = 300 # Cache version detection for 5 minutes
39
+ self._cached_version: Optional[Tuple[EnvironmentVersionInfo, datetime]] = None
40
+
41
+ async def get_environment_version(
42
+ self, use_cache: bool = True
43
+ ) -> VersionDetectionResult:
44
+ """Get current environment version based on installed modules
45
+
46
+ Args:
47
+ use_cache: Whether to use cached version if available
48
+
49
+ Returns:
50
+ VersionDetectionResult with complete module details or error information
51
+ """
52
+ start_time = time.time()
53
+ cache_hit = False
54
+
55
+ try:
56
+ # Check cache first
57
+ if use_cache and self._cached_version:
58
+ cached_version, cached_at = self._cached_version
59
+ age = (datetime.now(timezone.utc) - cached_at).total_seconds()
60
+ if age < self._cache_ttl:
61
+ logger.debug(f"Using cached version detection (age: {age:.1f}s)")
62
+ cache_hit = True
63
+ return VersionDetectionResult(
64
+ success=True,
65
+ version_info=cached_version,
66
+ detection_time_ms=(time.time() - start_time) * 1000,
67
+ modules_count=len(cached_version.modules),
68
+ cache_hit=True,
69
+ )
70
+
71
+ logger.info("Detecting environment version using GetInstalledModules")
72
+
73
+ # Use the get_installed_modules method from MetadataAPIOperations
74
+ module_strings = await self.api.get_installed_modules()
75
+
76
+ # Parse module information
77
+ modules = self._parse_modules_list(module_strings)
78
+ logger.debug(f"Successfully parsed {len(modules)} modules")
79
+
80
+ # Get fallback version info
81
+ app_version, platform_version = await self._get_fallback_versions()
82
+
83
+ # Create version info
84
+ version_info = EnvironmentVersionInfo(
85
+ environment_id=0, # Will be set by cache manager
86
+ version_hash="", # Will be computed in __post_init__
87
+ modules_hash="", # Will be computed in __post_init__
88
+ application_version=app_version,
89
+ platform_version=platform_version,
90
+ modules=modules,
91
+ computed_at=datetime.now(timezone.utc),
92
+ is_active=True,
93
+ )
94
+
95
+ # Cache the result
96
+ self._cached_version = (version_info, datetime.now(timezone.utc))
97
+
98
+ detection_time = (time.time() - start_time) * 1000
99
+ logger.info(
100
+ f"Version detection complete: {len(modules)} modules, "
101
+ f"hash: {version_info.version_hash}, time: {detection_time:.1f}ms"
102
+ )
103
+
104
+ return VersionDetectionResult(
105
+ success=True,
106
+ version_info=version_info,
107
+ detection_time_ms=detection_time,
108
+ modules_count=len(modules),
109
+ cache_hit=cache_hit,
110
+ )
111
+
112
+ except Exception as e:
113
+ detection_time = (time.time() - start_time) * 1000
114
+ error_msg = f"Failed to detect environment version: {e}"
115
+ logger.error(error_msg)
116
+
117
+ return VersionDetectionResult(
118
+ success=False,
119
+ error_message=error_msg,
120
+ detection_time_ms=detection_time,
121
+ cache_hit=cache_hit,
122
+ )
123
+
124
+ def _parse_modules_list(self, module_strings: List[str]) -> List[ModuleVersionInfo]:
125
+ """Parse list of module strings into ModuleVersionInfo objects
126
+
127
+ Args:
128
+ module_strings: List of module strings from GetInstalledModules
129
+
130
+ Returns:
131
+ List of ModuleVersionInfo objects
132
+
133
+ Raises:
134
+ VersionDetectionError: If parsing fails
135
+ """
136
+ try:
137
+ if not isinstance(module_strings, list):
138
+ raise VersionDetectionError("Invalid input: expected list of strings")
139
+
140
+ if not module_strings:
141
+ raise VersionDetectionError("No modules found in response")
142
+
143
+ modules = []
144
+ parse_errors = []
145
+
146
+ for module_string in module_strings:
147
+ try:
148
+ module = ModuleVersionInfo.parse_from_string(module_string)
149
+ modules.append(module)
150
+ except ValueError as e:
151
+ parse_errors.append(f"Module parse error: {e}")
152
+ continue
153
+
154
+ if not modules:
155
+ error_msg = f"No valid modules found. Parse errors: {'; '.join(parse_errors[:5])}"
156
+ raise VersionDetectionError(error_msg)
157
+
158
+ if parse_errors:
159
+ logger.warning(
160
+ f"Failed to parse {len(parse_errors)} module strings out of {len(module_strings)}"
161
+ )
162
+
163
+ logger.debug(
164
+ f"Successfully parsed {len(modules)} modules from {len(module_strings)} strings"
165
+ )
166
+ return modules
167
+
168
+ except Exception as e:
169
+ if isinstance(e, VersionDetectionError):
170
+ raise
171
+ raise VersionDetectionError(f"Failed to parse modules list: {e}")
172
+
173
+ def _parse_modules_response(self, response: Dict) -> List[ModuleVersionInfo]:
174
+ """Parse GetInstalledModules response into ModuleVersionInfo objects
175
+
176
+ Args:
177
+ response: Response from GetInstalledModules action
178
+
179
+ Returns:
180
+ List of ModuleVersionInfo objects
181
+
182
+ Raises:
183
+ VersionDetectionError: If response format is invalid
184
+ """
185
+ try:
186
+ if not response.get("success", False):
187
+ raise VersionDetectionError("GetInstalledModules action failed")
188
+
189
+ result = response.get("result", {})
190
+ module_strings = result.get("value", [])
191
+
192
+ if not isinstance(module_strings, list):
193
+ raise VersionDetectionError(
194
+ "Invalid response format: expected list of strings"
195
+ )
196
+
197
+ if not module_strings:
198
+ raise VersionDetectionError("No modules found in response")
199
+
200
+ modules = []
201
+ parse_errors = []
202
+
203
+ for module_string in module_strings:
204
+ try:
205
+ module = ModuleVersionInfo.parse_from_string(module_string)
206
+ modules.append(module)
207
+ except ValueError as e:
208
+ parse_errors.append(f"Module parse error: {e}")
209
+ continue
210
+
211
+ if not modules:
212
+ error_msg = f"No valid modules found. Parse errors: {'; '.join(parse_errors[:5])}"
213
+ raise VersionDetectionError(error_msg)
214
+
215
+ if parse_errors:
216
+ logger.warning(
217
+ f"Failed to parse {len(parse_errors)} module strings out of {len(module_strings)}"
218
+ )
219
+
220
+ logger.debug(
221
+ f"Successfully parsed {len(modules)} modules from {len(module_strings)} strings"
222
+ )
223
+ return modules
224
+
225
+ except Exception as e:
226
+ if isinstance(e, VersionDetectionError):
227
+ raise
228
+ raise VersionDetectionError(f"Failed to parse modules response: {e}")
229
+
230
+ async def _get_fallback_versions(self) -> Tuple[Optional[str], Optional[str]]:
231
+ """Get fallback application and platform versions"""
232
+ app_version = None
233
+ platform_version = None
234
+
235
+ try:
236
+ app_version = await self.api.get_application_version()
237
+ logger.debug(f"Got application version: {app_version}")
238
+ except Exception as e:
239
+ logger.warning(f"Failed to get application version: {e}")
240
+
241
+ try:
242
+ platform_version = await self.api.get_platform_build_version()
243
+ logger.debug(f"Got platform version: {platform_version}")
244
+ except Exception as e:
245
+ logger.warning(f"Failed to get platform version: {e}")
246
+
247
+ return app_version, platform_version
248
+
249
+ async def compare_versions(
250
+ self, version1: EnvironmentVersionInfo, version2: EnvironmentVersionInfo
251
+ ) -> Dict[str, Any]:
252
+ """Compare two environment versions and return differences
253
+
254
+ Args:
255
+ version1: First version to compare
256
+ version2: Second version to compare
257
+
258
+ Returns:
259
+ Dictionary with comparison results including added, removed, and updated modules
260
+ """
261
+ comparison = {
262
+ "identical": version1.modules_hash == version2.modules_hash,
263
+ "hash_match": version1.version_hash == version2.version_hash,
264
+ "module_count_diff": len(version2.modules) - len(version1.modules),
265
+ "added_modules": [],
266
+ "removed_modules": [],
267
+ "updated_modules": [],
268
+ "identical_modules": [],
269
+ }
270
+
271
+ # Create module dictionaries for comparison
272
+ v1_modules = {m.module_id: m for m in version1.modules}
273
+ v2_modules = {m.module_id: m for m in version2.modules}
274
+
275
+ # Find differences
276
+ for module_id, module in v2_modules.items():
277
+ if module_id not in v1_modules:
278
+ comparison["added_modules"].append(module.to_dict())
279
+ elif v1_modules[module_id].version != module.version:
280
+ comparison["updated_modules"].append(
281
+ {
282
+ "module_id": module_id,
283
+ "old_version": v1_modules[module_id].version,
284
+ "new_version": module.version,
285
+ "old_publisher": v1_modules[module_id].publisher,
286
+ "new_publisher": module.publisher,
287
+ }
288
+ )
289
+ else:
290
+ comparison["identical_modules"].append(module_id)
291
+
292
+ for module_id in v1_modules:
293
+ if module_id not in v2_modules:
294
+ comparison["removed_modules"].append(v1_modules[module_id].to_dict())
295
+
296
+ # Add summary statistics
297
+ comparison["summary"] = {
298
+ "total_changes": len(comparison["added_modules"])
299
+ + len(comparison["removed_modules"])
300
+ + len(comparison["updated_modules"]),
301
+ "modules_added": len(comparison["added_modules"]),
302
+ "modules_removed": len(comparison["removed_modules"]),
303
+ "modules_updated": len(comparison["updated_modules"]),
304
+ "modules_unchanged": len(comparison["identical_modules"]),
305
+ }
306
+
307
+ return comparison
308
+
309
+ def clear_cache(self):
310
+ """Clear cached version detection"""
311
+ self._cached_version = None
312
+ logger.debug("Version detection cache cleared")
313
+
314
+ def get_cache_info(self) -> Dict[str, Any]:
315
+ """Get information about cached version"""
316
+ if not self._cached_version:
317
+ return {"cached": False}
318
+
319
+ cached_version, cached_at = self._cached_version
320
+ age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
321
+
322
+ return {
323
+ "cached": True,
324
+ "cache_age_seconds": age_seconds,
325
+ "cache_expires_in_seconds": self._cache_ttl - age_seconds,
326
+ "version_hash": cached_version.version_hash,
327
+ "modules_count": len(cached_version.modules),
328
+ "cached_at": cached_at.isoformat(),
329
+ }
330
+
331
+
332
+ # Helper functions for version utilities
333
+ def compute_version_signature(modules: List[ModuleVersionInfo]) -> str:
334
+ """Compute a compact signature for version identification
335
+
336
+ Args:
337
+ modules: List of module version info
338
+
339
+ Returns:
340
+ Compact version signature string
341
+ """
342
+ if not modules:
343
+ return "empty"
344
+
345
+ # Sort modules and create compact signature
346
+ sorted_modules = sorted(modules, key=lambda m: m.module_id)
347
+
348
+ # Use first 5 and last 5 modules for signature to keep it compact
349
+ signature_modules = (
350
+ sorted_modules[:5] + sorted_modules[-5:]
351
+ if len(sorted_modules) > 10
352
+ else sorted_modules
353
+ )
354
+
355
+ sig_parts = []
356
+ for module in signature_modules:
357
+ # Use module_id and version only
358
+ sig_parts.append(f"{module.module_id[:10]}:{module.version}")
359
+
360
+ signature = "|".join(sig_parts)
361
+ return hashlib.sha256(signature.encode()).hexdigest()[:12]
362
+
363
+
364
+ def extract_core_modules(modules: List[ModuleVersionInfo]) -> List[ModuleVersionInfo]:
365
+ """Extract core/essential modules for quick comparison
366
+
367
+ Args:
368
+ modules: Complete list of modules
369
+
370
+ Returns:
371
+ List of core modules
372
+ """
373
+ core_module_patterns = [
374
+ "ApplicationPlatform",
375
+ "ApplicationFoundation",
376
+ "ApplicationSuite",
377
+ "ApplicationCommon",
378
+ "Directory",
379
+ "DataManagement",
380
+ ]
381
+
382
+ core_modules = []
383
+ for module in modules:
384
+ if any(pattern in module.module_id for pattern in core_module_patterns):
385
+ core_modules.append(module)
386
+
387
+ return sorted(core_modules, key=lambda m: m.module_id)
388
+
389
+
390
+ def validate_modules_consistency(modules: List[ModuleVersionInfo]) -> Dict[str, Any]:
391
+ """Validate consistency of module versions
392
+
393
+ Args:
394
+ modules: List of modules to validate
395
+
396
+ Returns:
397
+ Dictionary with validation results
398
+ """
399
+ validation = {
400
+ "valid": True,
401
+ "issues": [],
402
+ "warnings": [],
403
+ "statistics": {
404
+ "total_modules": len(modules),
405
+ "unique_publishers": len(set(m.publisher for m in modules)),
406
+ "unique_versions": len(set(m.version for m in modules)),
407
+ },
408
+ }
409
+
410
+ # Check for duplicate module IDs
411
+ module_ids = [m.module_id for m in modules]
412
+ duplicates = [mid for mid in set(module_ids) if module_ids.count(mid) > 1]
413
+ if duplicates:
414
+ validation["valid"] = False
415
+ validation["issues"].append(f"Duplicate module IDs found: {duplicates}")
416
+
417
+ # Check for missing core modules
418
+ core_patterns = ["ApplicationPlatform", "ApplicationFoundation"]
419
+ found_core = [p for p in core_patterns if any(p in m.module_id for m in modules)]
420
+ if len(found_core) < len(core_patterns):
421
+ missing = [p for p in core_patterns if p not in found_core]
422
+ validation["warnings"].append(f"Missing core modules: {missing}")
423
+
424
+ # Check version consistency for related modules
425
+ version_groups = {}
426
+ for module in modules:
427
+ # Group by major version
428
+ major_version = (
429
+ module.version.split(".")[0] if "." in module.version else module.version
430
+ )
431
+ if major_version not in version_groups:
432
+ version_groups[major_version] = []
433
+ version_groups[major_version].append(module.module_id)
434
+
435
+ validation["statistics"]["version_groups"] = {
436
+ k: len(v) for k, v in version_groups.items()
437
+ }
438
+
439
+ return validation