d365fo-client 0.1.0__py3-none-any.whl → 0.2.2__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 +4 -48
- d365fo_client/client.py +102 -56
- 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/models.py +2 -2
- d365fo_client/mcp/server.py +90 -5
- d365fo_client/mcp/tools/connection_tools.py +7 -0
- d365fo_client/mcp/tools/profile_tools.py +15 -0
- d365fo_client/metadata_api.py +223 -6
- d365fo_client/metadata_v2/cache_v2.py +267 -97
- d365fo_client/metadata_v2/database_v2.py +93 -2
- d365fo_client/metadata_v2/global_version_manager.py +60 -0
- d365fo_client/metadata_v2/label_utils.py +107 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +83 -9
- d365fo_client/models.py +19 -10
- d365fo_client/output.py +4 -4
- d365fo_client/profile_manager.py +12 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/METADATA +5 -1
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/RECORD +26 -25
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/WHEEL +0 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.1.0.dist-info → d365fo_client-0.2.2.dist-info}/top_level.txt +0 -0
@@ -296,7 +296,6 @@ class DatabaseSchemaV2:
|
|
296
296
|
language TEXT NOT NULL DEFAULT 'en-US',
|
297
297
|
label_text TEXT,
|
298
298
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
299
|
-
expires_at TIMESTAMP,
|
300
299
|
hit_count INTEGER DEFAULT 0,
|
301
300
|
last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
302
301
|
UNIQUE(global_version_id, label_id, language)
|
@@ -345,7 +344,6 @@ class DatabaseSchemaV2:
|
|
345
344
|
"CREATE INDEX IF NOT EXISTS idx_enumerations_version ON enumerations(global_version_id, name)",
|
346
345
|
# Labels indexes
|
347
346
|
"CREATE INDEX IF NOT EXISTS idx_labels_version_lookup ON labels_cache(global_version_id, label_id, language)",
|
348
|
-
"CREATE INDEX IF NOT EXISTS idx_labels_expires ON labels_cache(expires_at)",
|
349
347
|
# Search performance indexes
|
350
348
|
"CREATE INDEX IF NOT EXISTS idx_data_entities_search ON data_entities(global_version_id, data_service_enabled, entity_category)",
|
351
349
|
"CREATE INDEX IF NOT EXISTS idx_public_entities_search ON public_entities(global_version_id, is_read_only)",
|
@@ -544,6 +542,99 @@ class MetadataDatabaseV2:
|
|
544
542
|
|
545
543
|
return stats
|
546
544
|
|
545
|
+
async def get_statistics(self) -> Dict[str, Any]:
|
546
|
+
"""Alias for get_database_statistics for backward compatibility
|
547
|
+
|
548
|
+
Returns:
|
549
|
+
Dictionary with database statistics
|
550
|
+
"""
|
551
|
+
return await self.get_database_statistics()
|
552
|
+
|
553
|
+
async def get_environment_database_statistics(self, environment_id: int) -> Dict[str, Any]:
|
554
|
+
"""Get database statistics scoped to a specific environment
|
555
|
+
|
556
|
+
Args:
|
557
|
+
environment_id: Environment ID to get statistics for
|
558
|
+
|
559
|
+
Returns:
|
560
|
+
Dictionary with environment-scoped database statistics
|
561
|
+
"""
|
562
|
+
async with aiosqlite.connect(self.db_path) as db:
|
563
|
+
stats = {}
|
564
|
+
|
565
|
+
# Get active global versions for this environment
|
566
|
+
cursor = await db.execute(
|
567
|
+
"""SELECT DISTINCT global_version_id
|
568
|
+
FROM environment_versions
|
569
|
+
WHERE environment_id = ? AND is_active = 1""",
|
570
|
+
(environment_id,)
|
571
|
+
)
|
572
|
+
active_versions = [row[0] for row in await cursor.fetchall()]
|
573
|
+
|
574
|
+
if not active_versions:
|
575
|
+
# No active versions, return zero counts
|
576
|
+
return {
|
577
|
+
"data_entities_count": 0,
|
578
|
+
"public_entities_count": 0,
|
579
|
+
"entity_properties_count": 0,
|
580
|
+
"navigation_properties_count": 0,
|
581
|
+
"entity_actions_count": 0,
|
582
|
+
"enumerations_count": 0,
|
583
|
+
"labels_cache_count": 0,
|
584
|
+
"environment_statistics": {
|
585
|
+
"total_environments": 1,
|
586
|
+
"linked_versions": 0,
|
587
|
+
},
|
588
|
+
"database_size_bytes": None,
|
589
|
+
"database_size_mb": None,
|
590
|
+
}
|
591
|
+
|
592
|
+
# Create placeholders for SQL IN clause
|
593
|
+
version_placeholders = ",".join("?" for _ in active_versions)
|
594
|
+
|
595
|
+
# Environment-scoped metadata counts
|
596
|
+
tables = [
|
597
|
+
("data_entities", "entities"),
|
598
|
+
("public_entities", "public_entities"),
|
599
|
+
("entity_properties", "properties"),
|
600
|
+
("navigation_properties", "navigation_properties"),
|
601
|
+
("entity_actions", "actions"),
|
602
|
+
("enumerations", "enumerations"),
|
603
|
+
("labels_cache", "labels"),
|
604
|
+
]
|
605
|
+
|
606
|
+
for table, key in tables:
|
607
|
+
cursor = await db.execute(
|
608
|
+
f"SELECT COUNT(*) FROM {table} WHERE global_version_id IN ({version_placeholders})",
|
609
|
+
active_versions
|
610
|
+
)
|
611
|
+
stats[f"{table}_count"] = (await cursor.fetchone())[0]
|
612
|
+
|
613
|
+
# Environment-specific statistics
|
614
|
+
cursor = await db.execute(
|
615
|
+
"""SELECT
|
616
|
+
COUNT(DISTINCT ev.global_version_id) as linked_versions
|
617
|
+
FROM environment_versions ev
|
618
|
+
WHERE ev.environment_id = ? AND ev.is_active = 1""",
|
619
|
+
(environment_id,)
|
620
|
+
)
|
621
|
+
env_stats = await cursor.fetchone()
|
622
|
+
stats["environment_statistics"] = {
|
623
|
+
"total_environments": 1, # Current environment only
|
624
|
+
"linked_versions": env_stats[0] or 0,
|
625
|
+
}
|
626
|
+
|
627
|
+
# Database file size (shared across all environments)
|
628
|
+
try:
|
629
|
+
db_size = self.db_path.stat().st_size
|
630
|
+
stats["database_size_bytes"] = db_size
|
631
|
+
stats["database_size_mb"] = round(db_size / (1024 * 1024), 2)
|
632
|
+
except Exception:
|
633
|
+
stats["database_size_bytes"] = None
|
634
|
+
stats["database_size_mb"] = None
|
635
|
+
|
636
|
+
return stats
|
637
|
+
|
547
638
|
async def vacuum_database(self) -> bool:
|
548
639
|
"""Vacuum database to reclaim space
|
549
640
|
|
@@ -571,3 +571,63 @@ class GlobalVersionManager:
|
|
571
571
|
}
|
572
572
|
|
573
573
|
return stats
|
574
|
+
|
575
|
+
async def get_environment_version_statistics(self, environment_id: int) -> Dict[str, Any]:
|
576
|
+
"""Get version statistics scoped to a specific environment
|
577
|
+
|
578
|
+
Args:
|
579
|
+
environment_id: Environment ID to get statistics for
|
580
|
+
|
581
|
+
Returns:
|
582
|
+
Dictionary with environment-scoped version statistics
|
583
|
+
"""
|
584
|
+
async with aiosqlite.connect(self.db_path) as db:
|
585
|
+
stats = {}
|
586
|
+
|
587
|
+
# Get versions for this specific environment
|
588
|
+
cursor = await db.execute(
|
589
|
+
"""SELECT COUNT(DISTINCT global_version_id)
|
590
|
+
FROM environment_versions
|
591
|
+
WHERE environment_id = ? AND is_active = 1""",
|
592
|
+
(environment_id,)
|
593
|
+
)
|
594
|
+
stats["total_versions"] = (await cursor.fetchone())[0]
|
595
|
+
|
596
|
+
# This environment only
|
597
|
+
stats["total_environments"] = 1
|
598
|
+
|
599
|
+
# Reference statistics for this environment's versions
|
600
|
+
cursor = await db.execute(
|
601
|
+
"""SELECT
|
602
|
+
SUM(gv.reference_count) as total_references,
|
603
|
+
AVG(gv.reference_count) as avg_references,
|
604
|
+
MAX(gv.reference_count) as max_references,
|
605
|
+
COUNT(*) as versions_with_refs
|
606
|
+
FROM global_versions gv
|
607
|
+
INNER JOIN environment_versions ev ON gv.id = ev.global_version_id
|
608
|
+
WHERE ev.environment_id = ? AND ev.is_active = 1 AND gv.reference_count > 0""",
|
609
|
+
(environment_id,)
|
610
|
+
)
|
611
|
+
ref_stats = await cursor.fetchone()
|
612
|
+
stats["reference_statistics"] = {
|
613
|
+
"total_references": ref_stats[0] or 0,
|
614
|
+
"avg_references": round(ref_stats[1] or 0, 2),
|
615
|
+
"max_references": ref_stats[2] or 0,
|
616
|
+
"versions_with_references": ref_stats[3] or 0,
|
617
|
+
}
|
618
|
+
|
619
|
+
# Version age statistics for this environment
|
620
|
+
cursor = await db.execute(
|
621
|
+
"""SELECT
|
622
|
+
COUNT(*) as recent_versions
|
623
|
+
FROM global_versions gv
|
624
|
+
INNER JOIN environment_versions ev ON gv.id = ev.global_version_id
|
625
|
+
WHERE ev.environment_id = ? AND ev.is_active = 1
|
626
|
+
AND gv.last_used_at >= datetime('now', '-7 days')""",
|
627
|
+
(environment_id,)
|
628
|
+
)
|
629
|
+
stats["recent_activity"] = {
|
630
|
+
"versions_used_last_7_days": (await cursor.fetchone())[0]
|
631
|
+
}
|
632
|
+
|
633
|
+
return stats
|
@@ -0,0 +1,107 @@
|
|
1
|
+
"""Label processing utilities for metadata v2.
|
2
|
+
|
3
|
+
This module provides utilities for processing D365 Finance & Operations labels
|
4
|
+
with fallback logic for when label_id doesn't start with '@'.
|
5
|
+
|
6
|
+
The main principle is:
|
7
|
+
- If label_text is already set, preserve it
|
8
|
+
- If label_id doesn't start with '@', it's already display text - use it as label_text
|
9
|
+
- If label_id starts with '@', it's a label reference that needs API resolution
|
10
|
+
|
11
|
+
This ensures that direct text labels (like "Customer Groups") are properly
|
12
|
+
converted to label_text, while label references (like "@SYS123") are preserved
|
13
|
+
for later resolution through the label API.
|
14
|
+
|
15
|
+
Functions:
|
16
|
+
process_label_fallback: Core label processing logic
|
17
|
+
process_data_entity_labels: Process DataEntityInfo labels
|
18
|
+
process_public_entity_labels: Process PublicEntityInfo and property labels
|
19
|
+
process_enumeration_labels: Process EnumerationInfo and member labels
|
20
|
+
apply_label_fallback: Alias for process_label_fallback (retrieval context)
|
21
|
+
"""
|
22
|
+
|
23
|
+
from typing import Optional
|
24
|
+
|
25
|
+
from ..models import DataEntityInfo, EnumerationInfo, PublicEntityInfo
|
26
|
+
|
27
|
+
|
28
|
+
def process_label_fallback(label_id: Optional[str], label_text: Optional[str]) -> Optional[str]:
|
29
|
+
"""Process label text with fallback to label_id when it doesn't start with '@'
|
30
|
+
|
31
|
+
This function implements the core logic for handling D365 F&O labels where:
|
32
|
+
- If label_text is already set, use it
|
33
|
+
- If label_id doesn't start with '@', it's already display text
|
34
|
+
- If label_id starts with '@', it needs resolution via label API
|
35
|
+
|
36
|
+
Args:
|
37
|
+
label_id: Label identifier (may be actual text or @-prefixed ID)
|
38
|
+
label_text: Existing label text (may be None)
|
39
|
+
|
40
|
+
Returns:
|
41
|
+
Processed label text with fallback applied
|
42
|
+
"""
|
43
|
+
# If label_text is already set, use it
|
44
|
+
if label_text:
|
45
|
+
return label_text
|
46
|
+
|
47
|
+
# If label_id doesn't start with '@', it's already the text
|
48
|
+
if label_id and not label_id.startswith('@'):
|
49
|
+
return label_id
|
50
|
+
|
51
|
+
# Return None for @-prefixed labels without resolved text
|
52
|
+
return None
|
53
|
+
|
54
|
+
|
55
|
+
def process_data_entity_labels(entity: DataEntityInfo) -> DataEntityInfo:
|
56
|
+
"""Process data entity labels with fallback logic
|
57
|
+
|
58
|
+
Args:
|
59
|
+
entity: Data entity info to process
|
60
|
+
|
61
|
+
Returns:
|
62
|
+
Processed data entity info with label fallback applied
|
63
|
+
"""
|
64
|
+
entity.label_text = process_label_fallback(entity.label_id, entity.label_text)
|
65
|
+
return entity
|
66
|
+
|
67
|
+
|
68
|
+
def process_public_entity_labels(entity: PublicEntityInfo) -> PublicEntityInfo:
|
69
|
+
"""Process public entity labels with fallback logic
|
70
|
+
|
71
|
+
Args:
|
72
|
+
entity: Public entity info to process
|
73
|
+
|
74
|
+
Returns:
|
75
|
+
Processed public entity info with label fallback applied
|
76
|
+
"""
|
77
|
+
# Process entity label
|
78
|
+
entity.label_text = process_label_fallback(entity.label_id, entity.label_text)
|
79
|
+
|
80
|
+
# Process property labels
|
81
|
+
for prop in entity.properties:
|
82
|
+
prop.label_text = process_label_fallback(prop.label_id, prop.label_text)
|
83
|
+
|
84
|
+
return entity
|
85
|
+
|
86
|
+
|
87
|
+
def process_enumeration_labels(enumeration: EnumerationInfo) -> EnumerationInfo:
|
88
|
+
"""Process enumeration labels with fallback logic
|
89
|
+
|
90
|
+
Args:
|
91
|
+
enumeration: Enumeration info to process
|
92
|
+
|
93
|
+
Returns:
|
94
|
+
Processed enumeration info with label fallback applied
|
95
|
+
"""
|
96
|
+
# Process enumeration label
|
97
|
+
enumeration.label_text = process_label_fallback(enumeration.label_id, enumeration.label_text)
|
98
|
+
|
99
|
+
# Process member labels
|
100
|
+
for member in enumeration.members:
|
101
|
+
member.label_text = process_label_fallback(member.label_id, member.label_text)
|
102
|
+
|
103
|
+
return enumeration
|
104
|
+
|
105
|
+
|
106
|
+
# Alias for backward compatibility during data retrieval
|
107
|
+
apply_label_fallback = process_label_fallback
|
@@ -1,13 +1,11 @@
|
|
1
1
|
"""Smart sync manager with intelligent metadata synchronization strategies."""
|
2
2
|
|
3
|
-
import asyncio
|
4
3
|
import hashlib
|
5
4
|
import json
|
6
5
|
import logging
|
7
6
|
import time
|
8
7
|
from datetime import datetime, timezone
|
9
|
-
from
|
10
|
-
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set
|
8
|
+
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
|
11
9
|
|
12
10
|
# Use TYPE_CHECKING to avoid circular import
|
13
11
|
if TYPE_CHECKING:
|
@@ -19,7 +17,6 @@ from ..models import (
|
|
19
17
|
LabelInfo,
|
20
18
|
MetadataVersionInfo,
|
21
19
|
PublicEntityInfo,
|
22
|
-
QueryOptions,
|
23
20
|
SyncProgress,
|
24
21
|
SyncResult,
|
25
22
|
SyncStrategy,
|
@@ -509,14 +506,20 @@ class SmartSyncManagerV2:
|
|
509
506
|
counts = {}
|
510
507
|
|
511
508
|
async with aiosqlite.connect(self.cache.db_path) as db:
|
512
|
-
# Copy data entities
|
509
|
+
# Copy data entities with label processing
|
513
510
|
await db.execute(
|
514
511
|
"""INSERT INTO data_entities
|
515
512
|
(global_version_id, name, public_entity_name, public_collection_name,
|
516
513
|
label_id, label_text, entity_category, data_service_enabled,
|
517
514
|
data_management_enabled, is_read_only)
|
518
515
|
SELECT ?, name, public_entity_name, public_collection_name,
|
519
|
-
label_id,
|
516
|
+
label_id,
|
517
|
+
CASE
|
518
|
+
WHEN label_text IS NOT NULL AND label_text != '' THEN label_text
|
519
|
+
WHEN label_id IS NOT NULL AND label_id != '' AND NOT label_id LIKE '@%' THEN label_id
|
520
|
+
ELSE label_text
|
521
|
+
END as processed_label_text,
|
522
|
+
entity_category, data_service_enabled,
|
520
523
|
data_management_enabled, is_read_only
|
521
524
|
FROM data_entities
|
522
525
|
WHERE global_version_id = ?""",
|
@@ -524,19 +527,90 @@ class SmartSyncManagerV2:
|
|
524
527
|
)
|
525
528
|
counts["entities"] = db.total_changes
|
526
529
|
|
527
|
-
# Copy enumerations
|
530
|
+
# Copy enumerations with label processing
|
528
531
|
await db.execute(
|
529
532
|
"""INSERT INTO enumerations
|
530
533
|
(global_version_id, name, label_id, label_text)
|
531
|
-
SELECT ?, name, label_id,
|
534
|
+
SELECT ?, name, label_id,
|
535
|
+
CASE
|
536
|
+
WHEN label_text IS NOT NULL AND label_text != '' THEN label_text
|
537
|
+
WHEN label_id IS NOT NULL AND label_id != '' AND NOT label_id LIKE '@%' THEN label_id
|
538
|
+
ELSE label_text
|
539
|
+
END as processed_label_text
|
532
540
|
FROM enumerations
|
533
541
|
WHERE global_version_id = ?""",
|
534
542
|
(target_version_id, source_version_id),
|
535
543
|
)
|
536
544
|
counts["enumerations"] = db.total_changes
|
537
545
|
|
546
|
+
# Copy public entities with label processing
|
547
|
+
await db.execute(
|
548
|
+
"""INSERT INTO public_entities
|
549
|
+
(global_version_id, name, entity_set_name, label_id, label_text,
|
550
|
+
is_read_only, configuration_enabled)
|
551
|
+
SELECT ?, name, entity_set_name, label_id,
|
552
|
+
CASE
|
553
|
+
WHEN label_text IS NOT NULL AND label_text != '' THEN label_text
|
554
|
+
WHEN label_id IS NOT NULL AND label_id != '' AND NOT label_id LIKE '@%' THEN label_id
|
555
|
+
ELSE label_text
|
556
|
+
END as processed_label_text,
|
557
|
+
is_read_only, configuration_enabled
|
558
|
+
FROM public_entities
|
559
|
+
WHERE global_version_id = ?""",
|
560
|
+
(target_version_id, source_version_id),
|
561
|
+
)
|
562
|
+
|
563
|
+
# Copy entity properties with label processing
|
564
|
+
# Note: We need to get the new entity IDs for the relationships
|
565
|
+
await db.execute(
|
566
|
+
"""INSERT INTO entity_properties
|
567
|
+
(entity_id, global_version_id, name, type_name, data_type,
|
568
|
+
odata_xpp_type, label_id, label_text, is_key, is_mandatory,
|
569
|
+
configuration_enabled, allow_edit, allow_edit_on_create,
|
570
|
+
is_dimension, dimension_relation, is_dynamic_dimension,
|
571
|
+
dimension_legal_entity_property, dimension_type_property,
|
572
|
+
property_order)
|
573
|
+
SELECT pe_new.id as entity_id, ?, ep.name, ep.type_name, ep.data_type,
|
574
|
+
ep.odata_xpp_type, ep.label_id,
|
575
|
+
CASE
|
576
|
+
WHEN ep.label_text IS NOT NULL AND ep.label_text != '' THEN ep.label_text
|
577
|
+
WHEN ep.label_id IS NOT NULL AND ep.label_id != '' AND NOT ep.label_id LIKE '@%' THEN ep.label_id
|
578
|
+
ELSE ep.label_text
|
579
|
+
END as processed_label_text,
|
580
|
+
ep.is_key, ep.is_mandatory,
|
581
|
+
ep.configuration_enabled, ep.allow_edit, ep.allow_edit_on_create,
|
582
|
+
ep.is_dimension, ep.dimension_relation, ep.is_dynamic_dimension,
|
583
|
+
ep.dimension_legal_entity_property, ep.dimension_type_property,
|
584
|
+
ep.property_order
|
585
|
+
FROM entity_properties ep
|
586
|
+
JOIN public_entities pe_old ON ep.entity_id = pe_old.id
|
587
|
+
JOIN public_entities pe_new ON pe_new.name = pe_old.name AND pe_new.global_version_id = ?
|
588
|
+
WHERE ep.global_version_id = ?""",
|
589
|
+
(target_version_id, target_version_id, source_version_id),
|
590
|
+
)
|
591
|
+
|
592
|
+
# Copy enumeration members with label processing
|
593
|
+
await db.execute(
|
594
|
+
"""INSERT INTO enumeration_members
|
595
|
+
(enumeration_id, global_version_id, name, value,
|
596
|
+
label_id, label_text, configuration_enabled, member_order)
|
597
|
+
SELECT e_new.id as enumeration_id, ?, em.name, em.value,
|
598
|
+
em.label_id,
|
599
|
+
CASE
|
600
|
+
WHEN em.label_text IS NOT NULL AND em.label_text != '' THEN em.label_text
|
601
|
+
WHEN em.label_id IS NOT NULL AND em.label_id != '' AND NOT em.label_id LIKE '@%' THEN em.label_id
|
602
|
+
ELSE em.label_text
|
603
|
+
END as processed_label_text,
|
604
|
+
em.configuration_enabled, em.member_order
|
605
|
+
FROM enumeration_members em
|
606
|
+
JOIN enumerations e_old ON em.enumeration_id = e_old.id
|
607
|
+
JOIN enumerations e_new ON e_new.name = e_old.name AND e_new.global_version_id = ?
|
608
|
+
WHERE em.global_version_id = ?""",
|
609
|
+
(target_version_id, target_version_id, source_version_id),
|
610
|
+
)
|
611
|
+
|
538
612
|
# Copy other metadata tables as needed...
|
539
|
-
# This is a
|
613
|
+
# This is a more comprehensive implementation with label processing
|
540
614
|
|
541
615
|
await db.commit()
|
542
616
|
|
d365fo_client/models.py
CHANGED
@@ -4,7 +4,7 @@ import hashlib
|
|
4
4
|
import json
|
5
5
|
from dataclasses import dataclass, field
|
6
6
|
from datetime import datetime, timezone
|
7
|
-
from enum import Enum
|
7
|
+
from enum import Enum, StrEnum
|
8
8
|
from pathlib import Path
|
9
9
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
10
10
|
|
@@ -14,7 +14,16 @@ if TYPE_CHECKING:
|
|
14
14
|
from typing import ForwardRef
|
15
15
|
|
16
16
|
|
17
|
-
|
17
|
+
def _ensure_str_for_json(field):
|
18
|
+
"""Ensure field is JSON-serializable as string.
|
19
|
+
|
20
|
+
StrEnum fields automatically serialize as strings, but this handles
|
21
|
+
the edge case where a field might be None or already a string.
|
22
|
+
"""
|
23
|
+
return field # StrEnum automatically converts to string, None stays None
|
24
|
+
|
25
|
+
|
26
|
+
class EntityCategory(StrEnum):
|
18
27
|
"""D365 F&O Entity Categories"""
|
19
28
|
|
20
29
|
MASTER = "Master"
|
@@ -25,7 +34,7 @@ class EntityCategory(Enum):
|
|
25
34
|
PARAMETERS = "Parameters"
|
26
35
|
|
27
36
|
|
28
|
-
class ODataXppType(
|
37
|
+
class ODataXppType(StrEnum):
|
29
38
|
"""D365 F&O OData XPP Types"""
|
30
39
|
|
31
40
|
CONTAINER = "Container"
|
@@ -42,7 +51,7 @@ class ODataXppType(Enum):
|
|
42
51
|
VOID = "Void"
|
43
52
|
|
44
53
|
|
45
|
-
class ODataBindingKind(
|
54
|
+
class ODataBindingKind(StrEnum):
|
46
55
|
"""D365 F&O Action Binding Types"""
|
47
56
|
|
48
57
|
BOUND_TO_ENTITY_INSTANCE = "BoundToEntityInstance"
|
@@ -50,7 +59,7 @@ class ODataBindingKind(Enum):
|
|
50
59
|
UNBOUND = "Unbound"
|
51
60
|
|
52
61
|
|
53
|
-
class SyncStrategy(
|
62
|
+
class SyncStrategy(StrEnum):
|
54
63
|
"""Metadata synchronization strategies"""
|
55
64
|
|
56
65
|
FULL = "full"
|
@@ -59,7 +68,7 @@ class SyncStrategy(Enum):
|
|
59
68
|
SHARING_MODE = "sharing_mode"
|
60
69
|
|
61
70
|
|
62
|
-
class Cardinality(
|
71
|
+
class Cardinality(StrEnum):
|
63
72
|
"""Navigation Property Cardinality"""
|
64
73
|
|
65
74
|
SINGLE = "Single"
|
@@ -191,7 +200,7 @@ class PublicEntityActionInfo:
|
|
191
200
|
def to_dict(self) -> Dict[str, Any]:
|
192
201
|
return {
|
193
202
|
"name": self.name,
|
194
|
-
"binding_kind": self.binding_kind
|
203
|
+
"binding_kind": self.binding_kind, # StrEnum automatically serializes as string
|
195
204
|
"parameters": [param.to_dict() for param in self.parameters],
|
196
205
|
"return_type": self.return_type.to_dict() if self.return_type else None,
|
197
206
|
"field_lookup": self.field_lookup,
|
@@ -221,7 +230,7 @@ class DataEntityInfo:
|
|
221
230
|
"label_text": self.label_text,
|
222
231
|
"data_service_enabled": self.data_service_enabled,
|
223
232
|
"data_management_enabled": self.data_management_enabled,
|
224
|
-
"entity_category": self.entity_category,
|
233
|
+
"entity_category": self.entity_category, # StrEnum automatically serializes as string
|
225
234
|
"is_read_only": self.is_read_only,
|
226
235
|
}
|
227
236
|
|
@@ -438,7 +447,7 @@ class NavigationPropertyInfo:
|
|
438
447
|
"name": self.name,
|
439
448
|
"related_entity": self.related_entity,
|
440
449
|
"related_relation_name": self.related_relation_name,
|
441
|
-
"cardinality": self.cardinality
|
450
|
+
"cardinality": self.cardinality, # StrEnum automatically serializes as string
|
442
451
|
"constraints": [constraint.to_dict() for constraint in self.constraints],
|
443
452
|
}
|
444
453
|
|
@@ -503,7 +512,7 @@ class ActionInfo:
|
|
503
512
|
def to_dict(self) -> Dict[str, Any]:
|
504
513
|
return {
|
505
514
|
"name": self.name,
|
506
|
-
"binding_kind": self.binding_kind,
|
515
|
+
"binding_kind": self.binding_kind, # StrEnum automatically serializes as string
|
507
516
|
"entity_name": self.entity_name,
|
508
517
|
"entity_set_name": self.entity_set_name,
|
509
518
|
"parameters": [param.to_dict() for param in self.parameters],
|
d365fo_client/output.py
CHANGED
@@ -163,19 +163,19 @@ class OutputFormatter:
|
|
163
163
|
|
164
164
|
def format_success_message(message: str) -> str:
|
165
165
|
"""Format a success message with checkmark."""
|
166
|
-
return f"
|
166
|
+
return f"[OK] {message}"
|
167
167
|
|
168
168
|
|
169
169
|
def format_error_message(message: str) -> str:
|
170
170
|
"""Format an error message with X mark."""
|
171
|
-
return f"
|
171
|
+
return f"[ERROR] {message}"
|
172
172
|
|
173
173
|
|
174
174
|
def format_info_message(message: str) -> str:
|
175
175
|
"""Format an info message with info icon."""
|
176
|
-
return f"
|
176
|
+
return f"[INFO] {message}"
|
177
177
|
|
178
178
|
|
179
179
|
def format_warning_message(message: str) -> str:
|
180
180
|
"""Format a warning message with warning icon."""
|
181
|
-
return f"
|
181
|
+
return f"[WARNING] {message}"
|
d365fo_client/profile_manager.py
CHANGED
@@ -195,6 +195,18 @@ class ProfileManager:
|
|
195
195
|
logger.error(f"Error getting default profile: {e}")
|
196
196
|
return None
|
197
197
|
|
198
|
+
def reload_config(self) -> None:
|
199
|
+
"""Reload configuration from file.
|
200
|
+
|
201
|
+
This is useful when profiles have been modified and we need
|
202
|
+
to refresh the in-memory configuration data.
|
203
|
+
"""
|
204
|
+
try:
|
205
|
+
self.config_manager.reload_config()
|
206
|
+
logger.debug("Reloaded profile configuration")
|
207
|
+
except Exception as e:
|
208
|
+
logger.error(f"Error reloading configuration: {e}")
|
209
|
+
|
198
210
|
def set_default_profile(self, profile_name: str) -> bool:
|
199
211
|
"""Set the default profile.
|
200
212
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: d365fo-client
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.2.2
|
4
4
|
Summary: Microsoft Dynamics 365 Finance & Operations client
|
5
5
|
Author-email: Muhammad Afzaal <mo@thedataguy.pro>
|
6
6
|
License-Expression: MIT
|
@@ -911,6 +911,8 @@ Add to your VS Code `mcp.json` for GitHub Copilot with MCP:
|
|
911
911
|
"type": "stdio",
|
912
912
|
"command": "uvx",
|
913
913
|
"args": [
|
914
|
+
"--from",
|
915
|
+
"d365fo-client",
|
914
916
|
"d365fo-mcp-server"
|
915
917
|
],
|
916
918
|
"env": {
|
@@ -932,6 +934,8 @@ For environments requiring service principal authentication:
|
|
932
934
|
"type": "stdio",
|
933
935
|
"command": "uvx",
|
934
936
|
"args": [
|
937
|
+
"--from",
|
938
|
+
"d365fo-client",
|
935
939
|
"d365fo-mcp-server"
|
936
940
|
],
|
937
941
|
"env": {
|