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,573 @@
1
+ """Global version manager for cross-environment metadata sharing."""
2
+
3
+ import hashlib
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ import aiosqlite
9
+
10
+ from ..models import EnvironmentVersionInfo, GlobalVersionInfo, ModuleVersionInfo
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class GlobalVersionManager:
16
+ """Manages global version registry and cross-environment sharing"""
17
+
18
+ def __init__(self, db_path):
19
+ """Initialize global version manager
20
+
21
+ Args:
22
+ db_path: Path to metadata database
23
+ """
24
+ self.db_path = db_path
25
+
26
+ async def register_environment_version(
27
+ self, environment_id: int, modules: List[ModuleVersionInfo]
28
+ ) -> Tuple[int, bool]:
29
+ """Register environment version and get/create global version
30
+
31
+ Args:
32
+ environment_id: Environment ID
33
+ modules: List of module version information
34
+
35
+ Returns:
36
+ Tuple of (global_version_id, is_new_version)
37
+ """
38
+ # Calculate version hashes
39
+ modules_hash = self._calculate_modules_hash(modules)
40
+ version_hash = self._calculate_version_hash(modules)
41
+
42
+ async with aiosqlite.connect(self.db_path) as db:
43
+ # Check if this exact version already exists
44
+ cursor = await db.execute(
45
+ "SELECT id FROM global_versions WHERE modules_hash = ?", (modules_hash,)
46
+ )
47
+
48
+ existing_version = await cursor.fetchone()
49
+ if existing_version:
50
+ global_version_id = existing_version[0]
51
+ is_new_version = False
52
+
53
+ # Update reference count and last used
54
+ await db.execute(
55
+ """UPDATE global_versions
56
+ SET reference_count = reference_count + 1,
57
+ last_used_at = CURRENT_TIMESTAMP
58
+ WHERE id = ?""",
59
+ (global_version_id,),
60
+ )
61
+
62
+ logger.info(f"Using existing global version {global_version_id}")
63
+ else:
64
+ # Create new global version
65
+ cursor = await db.execute(
66
+ """INSERT INTO global_versions
67
+ (version_hash, modules_hash, created_by_environment_id, reference_count)
68
+ VALUES (?, ?, ?, 1)""",
69
+ (version_hash, modules_hash, environment_id),
70
+ )
71
+
72
+ global_version_id = cursor.lastrowid
73
+ is_new_version = True
74
+
75
+ # Store module information
76
+ await self._store_global_version_modules(db, global_version_id, modules)
77
+
78
+ logger.info(f"Created new global version {global_version_id}")
79
+
80
+ # Link environment to global version
81
+ await self._link_environment_to_version(
82
+ db, environment_id, global_version_id
83
+ )
84
+
85
+ await db.commit()
86
+
87
+ return global_version_id, is_new_version
88
+
89
+ def _calculate_modules_hash(self, modules: List[ModuleVersionInfo]) -> str:
90
+ """Calculate hash of sorted modules
91
+
92
+ Args:
93
+ modules: List of module version information
94
+
95
+ Returns:
96
+ SHA-256 hash of sorted modules
97
+ """
98
+ # Sort modules by ID for consistent hashing
99
+ sorted_modules = sorted(modules, key=lambda m: m.module_id)
100
+
101
+ # Create hash string
102
+ hash_data = []
103
+ for module in sorted_modules:
104
+ hash_data.append(f"{module.module_id}:{module.version}")
105
+
106
+ hash_string = "|".join(hash_data)
107
+ return hashlib.sha256(hash_string.encode()).hexdigest()
108
+
109
+ def _calculate_version_hash(self, modules: List[ModuleVersionInfo]) -> str:
110
+ """Calculate comprehensive version hash including all module details
111
+
112
+ Args:
113
+ modules: List of module version information
114
+
115
+ Returns:
116
+ SHA-256 hash of all module details
117
+ """
118
+ # Sort modules by ID for consistent hashing
119
+ sorted_modules = sorted(modules, key=lambda m: m.module_id)
120
+
121
+ # Create comprehensive hash string
122
+ hash_data = []
123
+ for module in sorted_modules:
124
+ module_data = [
125
+ module.module_id,
126
+ module.version or "",
127
+ module.name or "",
128
+ module.publisher or "",
129
+ module.display_name or "",
130
+ ]
131
+ hash_data.append(":".join(module_data))
132
+
133
+ hash_string = "|".join(hash_data)
134
+ return hashlib.sha256(hash_string.encode()).hexdigest()
135
+
136
+ async def _store_global_version_modules(
137
+ self,
138
+ db: aiosqlite.Connection,
139
+ global_version_id: int,
140
+ modules: List[ModuleVersionInfo],
141
+ ):
142
+ """Store module information for global version
143
+
144
+ Args:
145
+ db: Database connection
146
+ global_version_id: Global version ID
147
+ modules: List of module version information
148
+ """
149
+ # Sort modules for consistent ordering
150
+ sorted_modules = sorted(modules, key=lambda m: (m.module_id, m.name or ""))
151
+
152
+ for i, module in enumerate(sorted_modules):
153
+ await db.execute(
154
+ """INSERT INTO global_version_modules
155
+ (global_version_id, module_id, module_name, version,
156
+ publisher, display_name, sort_order)
157
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
158
+ (
159
+ global_version_id,
160
+ module.module_id,
161
+ module.name,
162
+ module.version,
163
+ module.publisher,
164
+ module.display_name,
165
+ i,
166
+ ),
167
+ )
168
+
169
+ async def _link_environment_to_version(
170
+ self, db: aiosqlite.Connection, environment_id: int, global_version_id: int
171
+ ):
172
+ """Link environment to global version
173
+
174
+ Args:
175
+ db: Database connection
176
+ environment_id: Environment ID
177
+ global_version_id: Global version ID
178
+ """
179
+ # Deactivate any existing active links for this environment
180
+ await db.execute(
181
+ """UPDATE environment_versions
182
+ SET is_active = 0
183
+ WHERE environment_id = ? AND is_active = 1""",
184
+ (environment_id,),
185
+ )
186
+
187
+ # Create or reactivate link
188
+ await db.execute(
189
+ """INSERT OR REPLACE INTO environment_versions
190
+ (environment_id, global_version_id, is_active, sync_status)
191
+ VALUES (?, ?, 1, 'pending')""",
192
+ (environment_id, global_version_id),
193
+ )
194
+
195
+ async def get_environment_version_info(
196
+ self, environment_id: int
197
+ ) -> Optional[Tuple[int, EnvironmentVersionInfo]]:
198
+ """Get current version info for environment
199
+
200
+ Args:
201
+ environment_id: Environment ID
202
+
203
+ Returns:
204
+ Tuple of (global_version_id, EnvironmentVersionInfo) if found, None otherwise
205
+ """
206
+ async with aiosqlite.connect(self.db_path) as db:
207
+ cursor = await db.execute(
208
+ """SELECT
209
+ ev.global_version_id,
210
+ ev.detected_at,
211
+ ev.sync_status,
212
+ gv.version_hash,
213
+ gv.modules_hash,
214
+ gv.reference_count
215
+ FROM environment_versions ev
216
+ JOIN global_versions gv ON ev.global_version_id = gv.id
217
+ WHERE ev.environment_id = ? AND ev.is_active = 1""",
218
+ (environment_id,),
219
+ )
220
+
221
+ row = await cursor.fetchone()
222
+ if not row:
223
+ return None
224
+
225
+ # Get modules for this version
226
+ modules = await self._get_global_version_modules(db, row[0])
227
+
228
+ version_info = EnvironmentVersionInfo(
229
+ environment_id=environment_id,
230
+ version_hash=row[3],
231
+ modules_hash=row[4],
232
+ modules=modules,
233
+ computed_at=(
234
+ datetime.fromisoformat(row[1])
235
+ if row[1]
236
+ else datetime.now(timezone.utc)
237
+ ),
238
+ is_active=True,
239
+ )
240
+
241
+ return row[0], version_info # Return (global_version_id, version_info)
242
+
243
+ async def _get_global_version_modules(
244
+ self, db: aiosqlite.Connection, global_version_id: int
245
+ ) -> List[ModuleVersionInfo]:
246
+ """Get modules for global version
247
+
248
+ Args:
249
+ db: Database connection
250
+ global_version_id: Global version ID
251
+
252
+ Returns:
253
+ List of module version information
254
+ """
255
+ cursor = await db.execute(
256
+ """SELECT module_id, module_name, version, publisher, display_name
257
+ FROM global_version_modules
258
+ WHERE global_version_id = ?
259
+ ORDER BY sort_order""",
260
+ (global_version_id,),
261
+ )
262
+
263
+ modules = []
264
+ for row in await cursor.fetchall():
265
+ modules.append(
266
+ ModuleVersionInfo(
267
+ module_id=row[0],
268
+ name=row[1],
269
+ version=row[2],
270
+ publisher=row[3],
271
+ display_name=row[4],
272
+ )
273
+ )
274
+
275
+ return modules
276
+
277
+ async def get_global_version_info(
278
+ self, global_version_id: int
279
+ ) -> Optional[GlobalVersionInfo]:
280
+ """Get global version information
281
+
282
+ Args:
283
+ global_version_id: Global version ID
284
+
285
+ Returns:
286
+ Global version info if found
287
+ """
288
+ async with aiosqlite.connect(self.db_path) as db:
289
+ cursor = await db.execute(
290
+ """SELECT
291
+ id, version_hash, modules_hash, first_seen_at,
292
+ last_used_at, reference_count, metadata_size_bytes,
293
+ created_by_environment_id
294
+ FROM global_versions
295
+ WHERE id = ?""",
296
+ (global_version_id,),
297
+ )
298
+
299
+ row = await cursor.fetchone()
300
+ if not row:
301
+ return None
302
+
303
+ # Get modules
304
+ modules = await self._get_global_version_modules(db, global_version_id)
305
+
306
+ # Get linked environments
307
+ cursor = await db.execute(
308
+ """SELECT me.base_url, me.environment_name
309
+ FROM environment_versions ev
310
+ JOIN metadata_environments me ON ev.environment_id = me.id
311
+ WHERE ev.global_version_id = ? AND ev.is_active = 1""",
312
+ (global_version_id,),
313
+ )
314
+
315
+ environments = []
316
+ for env_row in await cursor.fetchall():
317
+ environments.append(
318
+ {"base_url": env_row[0], "environment_name": env_row[1]}
319
+ )
320
+
321
+ return GlobalVersionInfo(
322
+ id=row[0],
323
+ version_hash=row[1],
324
+ modules_hash=row[2],
325
+ first_seen_at=datetime.fromisoformat(row[3]),
326
+ last_used_at=datetime.fromisoformat(row[4]),
327
+ reference_count=row[5],
328
+ sample_modules=(
329
+ modules[:10] if modules else []
330
+ ), # Use first 10 modules as sample
331
+ )
332
+
333
+ async def find_compatible_versions(
334
+ self, modules: List[ModuleVersionInfo], exact_match: bool = False
335
+ ) -> List[GlobalVersionInfo]:
336
+ """Find compatible global versions
337
+
338
+ Args:
339
+ modules: Target modules to find compatibility for
340
+ exact_match: If True, require exact module match
341
+
342
+ Returns:
343
+ List of compatible global versions, sorted by compatibility
344
+ """
345
+ target_modules_hash = self._calculate_modules_hash(modules)
346
+
347
+ async with aiosqlite.connect(self.db_path) as db:
348
+ if exact_match:
349
+ # Exact module match only
350
+ cursor = await db.execute(
351
+ """SELECT id FROM global_versions
352
+ WHERE modules_hash = ?
353
+ ORDER BY last_used_at DESC""",
354
+ (target_modules_hash,),
355
+ )
356
+ else:
357
+ # Get all versions for compatibility analysis
358
+ cursor = await db.execute(
359
+ """SELECT id FROM global_versions
360
+ ORDER BY reference_count DESC, last_used_at DESC"""
361
+ )
362
+
363
+ compatible_versions = []
364
+ for row in await cursor.fetchall():
365
+ version_info = await self.get_global_version_info(row[0])
366
+ if version_info:
367
+ if exact_match or self._is_compatible(
368
+ modules, version_info.modules
369
+ ):
370
+ compatible_versions.append(version_info)
371
+
372
+ return compatible_versions
373
+
374
+ def _is_compatible(
375
+ self,
376
+ target_modules: List[ModuleVersionInfo],
377
+ candidate_modules: List[ModuleVersionInfo],
378
+ ) -> bool:
379
+ """Check if modules are compatible
380
+
381
+ Args:
382
+ target_modules: Target modules
383
+ candidate_modules: Candidate modules to check
384
+
385
+ Returns:
386
+ True if modules are compatible
387
+ """
388
+ # Create dictionaries for easier lookup
389
+ target_dict = {m.module_id: m for m in target_modules}
390
+ candidate_dict = {m.module_id: m for m in candidate_modules}
391
+
392
+ # Check core modules compatibility
393
+ core_modules = [
394
+ "ApplicationPlatform",
395
+ "ApplicationFoundation",
396
+ "ApplicationSuite",
397
+ ]
398
+
399
+ for core_module in core_modules:
400
+ target_module = target_dict.get(core_module)
401
+ candidate_module = candidate_dict.get(core_module)
402
+
403
+ if target_module and candidate_module:
404
+ if target_module.version != candidate_module.version:
405
+ return False
406
+ elif target_module or candidate_module:
407
+ # One has core module, other doesn't - incompatible
408
+ return False
409
+
410
+ # For now, require exact core module match
411
+ # Future: implement semantic version compatibility
412
+ return True
413
+
414
+ async def cleanup_unused_versions(self, max_unused_days: int = 30) -> int:
415
+ """Clean up unused global versions
416
+
417
+ Args:
418
+ max_unused_days: Days after which unused versions can be cleaned
419
+
420
+ Returns:
421
+ Number of versions cleaned up
422
+ """
423
+ cutoff_date = datetime.now(timezone.utc).replace(tzinfo=None)
424
+ cutoff_date = cutoff_date.replace(hour=0, minute=0, second=0, microsecond=0)
425
+ cutoff_date = cutoff_date.timestamp() - (max_unused_days * 86400)
426
+ cutoff_timestamp = datetime.fromtimestamp(cutoff_date).isoformat()
427
+
428
+ async with aiosqlite.connect(self.db_path) as db:
429
+ # Find unused versions
430
+ cursor = await db.execute(
431
+ """SELECT id FROM global_versions
432
+ WHERE reference_count = 0
433
+ AND last_used_at < ?""",
434
+ (cutoff_timestamp,),
435
+ )
436
+
437
+ unused_versions = [row[0] for row in await cursor.fetchall()]
438
+
439
+ if not unused_versions:
440
+ return 0
441
+
442
+ # Delete related data
443
+ for global_version_id in unused_versions:
444
+ await self._delete_global_version_data(db, global_version_id)
445
+
446
+ await db.commit()
447
+
448
+ logger.info(f"Cleaned up {len(unused_versions)} unused global versions")
449
+ return len(unused_versions)
450
+
451
+ async def _delete_global_version_data(
452
+ self, db: aiosqlite.Connection, global_version_id: int
453
+ ):
454
+ """Delete all data for a global version
455
+
456
+ Args:
457
+ db: Database connection
458
+ global_version_id: Global version ID to delete
459
+ """
460
+ # Delete in dependency order
461
+ tables = [
462
+ "action_parameters",
463
+ "property_group_members",
464
+ "relation_constraints",
465
+ "entity_properties",
466
+ "navigation_properties",
467
+ "property_groups",
468
+ "entity_actions",
469
+ "enumeration_members",
470
+ "data_entities",
471
+ "public_entities",
472
+ "enumerations",
473
+ "labels_cache",
474
+ "global_version_modules",
475
+ "metadata_versions",
476
+ "environment_versions",
477
+ "global_versions",
478
+ ]
479
+
480
+ for table in tables:
481
+ if table == "global_versions":
482
+ # Final delete of the version record
483
+ await db.execute(
484
+ f"DELETE FROM {table} WHERE id = ?", (global_version_id,)
485
+ )
486
+ else:
487
+ # Delete related records
488
+ await db.execute(
489
+ f"DELETE FROM {table} WHERE global_version_id = ?",
490
+ (global_version_id,),
491
+ )
492
+
493
+ async def update_sync_status(
494
+ self,
495
+ environment_id: int,
496
+ global_version_id: int,
497
+ status: str,
498
+ duration_ms: Optional[int] = None,
499
+ ):
500
+ """Update sync status for environment version
501
+
502
+ Args:
503
+ environment_id: Environment ID
504
+ global_version_id: Global version ID
505
+ status: New sync status
506
+ duration_ms: Sync duration in milliseconds
507
+ """
508
+ async with aiosqlite.connect(self.db_path) as db:
509
+ if duration_ms is not None:
510
+ await db.execute(
511
+ """UPDATE environment_versions
512
+ SET sync_status = ?, last_sync_duration_ms = ?
513
+ WHERE environment_id = ? AND global_version_id = ?""",
514
+ (status, duration_ms, environment_id, global_version_id),
515
+ )
516
+ else:
517
+ await db.execute(
518
+ """UPDATE environment_versions
519
+ SET sync_status = ?
520
+ WHERE environment_id = ? AND global_version_id = ?""",
521
+ (status, environment_id, global_version_id),
522
+ )
523
+
524
+ await db.commit()
525
+
526
+ async def get_version_statistics(self) -> Dict[str, Any]:
527
+ """Get global version statistics
528
+
529
+ Returns:
530
+ Dictionary with version statistics
531
+ """
532
+ async with aiosqlite.connect(self.db_path) as db:
533
+ stats = {}
534
+
535
+ # Basic counts
536
+ cursor = await db.execute("SELECT COUNT(*) FROM global_versions")
537
+ stats["total_versions"] = (await cursor.fetchone())[0]
538
+
539
+ cursor = await db.execute(
540
+ "SELECT COUNT(DISTINCT environment_id) FROM environment_versions"
541
+ )
542
+ stats["total_environments"] = (await cursor.fetchone())[0]
543
+
544
+ # Reference statistics
545
+ cursor = await db.execute(
546
+ """SELECT
547
+ SUM(reference_count) as total_references,
548
+ AVG(reference_count) as avg_references,
549
+ MAX(reference_count) as max_references,
550
+ COUNT(*) as versions_with_refs
551
+ FROM global_versions
552
+ WHERE reference_count > 0"""
553
+ )
554
+ ref_stats = await cursor.fetchone()
555
+ stats["reference_statistics"] = {
556
+ "total_references": ref_stats[0] or 0,
557
+ "avg_references": round(ref_stats[1] or 0, 2),
558
+ "max_references": ref_stats[2] or 0,
559
+ "versions_with_references": ref_stats[3] or 0,
560
+ }
561
+
562
+ # Version age statistics
563
+ cursor = await db.execute(
564
+ """SELECT
565
+ COUNT(*) as recent_versions
566
+ FROM global_versions
567
+ WHERE last_used_at >= datetime('now', '-7 days')"""
568
+ )
569
+ stats["recent_activity"] = {
570
+ "versions_used_last_7_days": (await cursor.fetchone())[0]
571
+ }
572
+
573
+ return stats