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