d365fo-client 0.2.1__py3-none-any.whl → 0.2.3__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/auth.py +48 -9
- d365fo_client/client.py +84 -44
- d365fo_client/credential_sources.py +431 -0
- d365fo_client/mcp/client_manager.py +8 -0
- d365fo_client/mcp/main.py +39 -17
- d365fo_client/mcp/models.py +2 -2
- d365fo_client/mcp/server.py +69 -22
- d365fo_client/mcp/tools/__init__.py +2 -0
- d365fo_client/mcp/tools/connection_tools.py +7 -0
- d365fo_client/mcp/tools/profile_tools.py +261 -2
- d365fo_client/mcp/tools/sync_tools.py +503 -0
- d365fo_client/metadata_api.py +68 -1
- d365fo_client/metadata_v2/cache_v2.py +26 -19
- d365fo_client/metadata_v2/database_v2.py +93 -0
- d365fo_client/metadata_v2/global_version_manager.py +62 -4
- d365fo_client/metadata_v2/sync_manager_v2.py +1 -1
- d365fo_client/metadata_v2/sync_session_manager.py +1043 -0
- d365fo_client/models.py +41 -13
- d365fo_client/profile_manager.py +7 -1
- d365fo_client/profiles.py +28 -1
- d365fo_client/sync_models.py +181 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/METADATA +48 -17
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/RECORD +28 -24
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.1.dist-info → d365fo_client-0.2.3.dist-info}/top_level.txt +0 -0
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
|
|
@@ -12,9 +12,19 @@ from .utils import get_environment_cache_directory
|
|
12
12
|
|
13
13
|
if TYPE_CHECKING:
|
14
14
|
from typing import ForwardRef
|
15
|
+
from .credential_sources import CredentialSource
|
15
16
|
|
16
17
|
|
17
|
-
|
18
|
+
def _ensure_str_for_json(field):
|
19
|
+
"""Ensure field is JSON-serializable as string.
|
20
|
+
|
21
|
+
StrEnum fields automatically serialize as strings, but this handles
|
22
|
+
the edge case where a field might be None or already a string.
|
23
|
+
"""
|
24
|
+
return field # StrEnum automatically converts to string, None stays None
|
25
|
+
|
26
|
+
|
27
|
+
class EntityCategory(StrEnum):
|
18
28
|
"""D365 F&O Entity Categories"""
|
19
29
|
|
20
30
|
MASTER = "Master"
|
@@ -25,7 +35,7 @@ class EntityCategory(Enum):
|
|
25
35
|
PARAMETERS = "Parameters"
|
26
36
|
|
27
37
|
|
28
|
-
class ODataXppType(
|
38
|
+
class ODataXppType(StrEnum):
|
29
39
|
"""D365 F&O OData XPP Types"""
|
30
40
|
|
31
41
|
CONTAINER = "Container"
|
@@ -42,7 +52,7 @@ class ODataXppType(Enum):
|
|
42
52
|
VOID = "Void"
|
43
53
|
|
44
54
|
|
45
|
-
class ODataBindingKind(
|
55
|
+
class ODataBindingKind(StrEnum):
|
46
56
|
"""D365 F&O Action Binding Types"""
|
47
57
|
|
48
58
|
BOUND_TO_ENTITY_INSTANCE = "BoundToEntityInstance"
|
@@ -50,16 +60,19 @@ class ODataBindingKind(Enum):
|
|
50
60
|
UNBOUND = "Unbound"
|
51
61
|
|
52
62
|
|
53
|
-
class SyncStrategy(
|
63
|
+
class SyncStrategy(StrEnum):
|
54
64
|
"""Metadata synchronization strategies"""
|
55
65
|
|
56
66
|
FULL = "full"
|
57
67
|
INCREMENTAL = "incremental"
|
58
68
|
ENTITIES_ONLY = "entities_only"
|
69
|
+
LABELS_ONLY = "labels_only"
|
59
70
|
SHARING_MODE = "sharing_mode"
|
71
|
+
FULL_WITHOUT_LABELS = "full_without_labels"
|
72
|
+
|
60
73
|
|
61
74
|
|
62
|
-
class Cardinality(
|
75
|
+
class Cardinality(StrEnum):
|
63
76
|
"""Navigation Property Cardinality"""
|
64
77
|
|
65
78
|
SINGLE = "Single"
|
@@ -71,6 +84,7 @@ class FOClientConfig:
|
|
71
84
|
"""Configuration for F&O Client"""
|
72
85
|
|
73
86
|
base_url: str
|
87
|
+
auth_mode: Optional[str] = "defaut" # default | client_credentials
|
74
88
|
client_id: Optional[str] = None
|
75
89
|
client_secret: Optional[str] = None
|
76
90
|
tenant_id: Optional[str] = None
|
@@ -89,6 +103,8 @@ class FOClientConfig:
|
|
89
103
|
max_memory_cache_size: int = 1000
|
90
104
|
# Cache-first behavior configuration
|
91
105
|
use_cache_first: bool = True
|
106
|
+
# Credential source configuration
|
107
|
+
credential_source: Optional["CredentialSource"] = None
|
92
108
|
|
93
109
|
def __post_init__(self):
|
94
110
|
"""Post-initialization to set default cache directory if not provided."""
|
@@ -191,7 +207,7 @@ class PublicEntityActionInfo:
|
|
191
207
|
def to_dict(self) -> Dict[str, Any]:
|
192
208
|
return {
|
193
209
|
"name": self.name,
|
194
|
-
"binding_kind": self.binding_kind
|
210
|
+
"binding_kind": self.binding_kind, # StrEnum automatically serializes as string
|
195
211
|
"parameters": [param.to_dict() for param in self.parameters],
|
196
212
|
"return_type": self.return_type.to_dict() if self.return_type else None,
|
197
213
|
"field_lookup": self.field_lookup,
|
@@ -221,7 +237,7 @@ class DataEntityInfo:
|
|
221
237
|
"label_text": self.label_text,
|
222
238
|
"data_service_enabled": self.data_service_enabled,
|
223
239
|
"data_management_enabled": self.data_management_enabled,
|
224
|
-
"entity_category": self.entity_category
|
240
|
+
"entity_category": self.entity_category, # StrEnum automatically serializes as string
|
225
241
|
"is_read_only": self.is_read_only,
|
226
242
|
}
|
227
243
|
|
@@ -438,7 +454,7 @@ class NavigationPropertyInfo:
|
|
438
454
|
"name": self.name,
|
439
455
|
"related_entity": self.related_entity,
|
440
456
|
"related_relation_name": self.related_relation_name,
|
441
|
-
"cardinality": self.cardinality
|
457
|
+
"cardinality": self.cardinality, # StrEnum automatically serializes as string
|
442
458
|
"constraints": [constraint.to_dict() for constraint in self.constraints],
|
443
459
|
}
|
444
460
|
|
@@ -503,7 +519,7 @@ class ActionInfo:
|
|
503
519
|
def to_dict(self) -> Dict[str, Any]:
|
504
520
|
return {
|
505
521
|
"name": self.name,
|
506
|
-
"binding_kind": self.binding_kind
|
522
|
+
"binding_kind": self.binding_kind, # StrEnum automatically serializes as string
|
507
523
|
"entity_name": self.entity_name,
|
508
524
|
"entity_set_name": self.entity_set_name,
|
509
525
|
"parameters": [param.to_dict() for param in self.parameters],
|
@@ -729,9 +745,9 @@ class GlobalVersionInfo:
|
|
729
745
|
first_seen_at: datetime
|
730
746
|
last_used_at: datetime
|
731
747
|
reference_count: int
|
732
|
-
|
748
|
+
modules: List[ModuleVersionInfo] = field(
|
733
749
|
default_factory=list
|
734
|
-
) #
|
750
|
+
) # Modules for debugging
|
735
751
|
|
736
752
|
def to_dict(self) -> Dict[str, Any]:
|
737
753
|
"""Convert to dictionary for JSON serialization"""
|
@@ -742,7 +758,7 @@ class GlobalVersionInfo:
|
|
742
758
|
"first_seen_at": self.first_seen_at.isoformat(),
|
743
759
|
"last_used_at": self.last_used_at.isoformat(),
|
744
760
|
"reference_count": self.reference_count,
|
745
|
-
"
|
761
|
+
"modules": [module.to_dict() for module in self.modules],
|
746
762
|
}
|
747
763
|
|
748
764
|
|
@@ -860,3 +876,15 @@ class SyncResult:
|
|
860
876
|
action_count: int
|
861
877
|
enumeration_count: int
|
862
878
|
label_count: int
|
879
|
+
|
880
|
+
def to_dict(self) -> dict:
|
881
|
+
"""Convert to dictionary for JSON serialization"""
|
882
|
+
return {
|
883
|
+
"success": self.success,
|
884
|
+
"error": self.error,
|
885
|
+
"duration_ms": self.duration_ms,
|
886
|
+
"entity_count": self.entity_count,
|
887
|
+
"action_count": self.action_count,
|
888
|
+
"enumeration_count": self.enumeration_count,
|
889
|
+
"label_count": self.label_count
|
890
|
+
}
|
d365fo_client/profile_manager.py
CHANGED
@@ -7,13 +7,15 @@ import logging
|
|
7
7
|
import os
|
8
8
|
from dataclasses import asdict
|
9
9
|
from pathlib import Path
|
10
|
-
from typing import Any, Dict, List, Optional
|
10
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
11
11
|
|
12
12
|
import yaml
|
13
13
|
|
14
14
|
from .config import ConfigManager
|
15
15
|
from .models import FOClientConfig
|
16
16
|
from .profiles import Profile
|
17
|
+
if TYPE_CHECKING:
|
18
|
+
from .credential_sources import CredentialSource
|
17
19
|
|
18
20
|
logger = logging.getLogger(__name__)
|
19
21
|
|
@@ -76,6 +78,8 @@ class ProfileManager:
|
|
76
78
|
language: str = "en-US",
|
77
79
|
cache_dir: Optional[str] = None,
|
78
80
|
description: Optional[str] = None,
|
81
|
+
credential_source: Optional["CredentialSource"] = None
|
82
|
+
|
79
83
|
) -> bool:
|
80
84
|
"""Create a new profile.
|
81
85
|
|
@@ -93,6 +97,7 @@ class ProfileManager:
|
|
93
97
|
language: Default language code
|
94
98
|
cache_dir: Cache directory path
|
95
99
|
description: Profile description (stored separately from CLI profile)
|
100
|
+
credential_source: Credential source configuration
|
96
101
|
|
97
102
|
Returns:
|
98
103
|
True if created successfully
|
@@ -120,6 +125,7 @@ class ProfileManager:
|
|
120
125
|
cache_dir=cache_dir,
|
121
126
|
description=description,
|
122
127
|
output_format="table", # Default for CLI compatibility
|
128
|
+
credential_source=credential_source
|
123
129
|
)
|
124
130
|
|
125
131
|
self.config_manager.save_profile(profile)
|
d365fo_client/profiles.py
CHANGED
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
6
6
|
|
7
7
|
if TYPE_CHECKING:
|
8
8
|
from .models import FOClientConfig
|
9
|
+
from .credential_sources import CredentialSource
|
9
10
|
|
10
11
|
logger = logging.getLogger(__name__)
|
11
12
|
|
@@ -24,8 +25,10 @@ class Profile:
|
|
24
25
|
client_id: Optional[str] = None
|
25
26
|
client_secret: Optional[str] = None
|
26
27
|
tenant_id: Optional[str] = None
|
28
|
+
use_default_credentials: Optional[bool] = None # None means derive from auth_mode
|
27
29
|
verify_ssl: bool = True
|
28
30
|
timeout: int = 60
|
31
|
+
credential_source: Optional["CredentialSource"] = None
|
29
32
|
|
30
33
|
# Cache settings
|
31
34
|
use_label_cache: bool = True
|
@@ -43,18 +46,26 @@ class Profile:
|
|
43
46
|
"""Convert profile to FOClientConfig."""
|
44
47
|
from .models import FOClientConfig
|
45
48
|
|
49
|
+
# Determine use_default_credentials: explicit setting takes precedence over auth_mode
|
50
|
+
if self.use_default_credentials is not None:
|
51
|
+
use_default_creds = self.use_default_credentials
|
52
|
+
else:
|
53
|
+
use_default_creds = self.auth_mode == "default"
|
54
|
+
|
46
55
|
return FOClientConfig(
|
47
56
|
base_url=self.base_url,
|
57
|
+
auth_mode=self.auth_mode,
|
48
58
|
client_id=self.client_id,
|
49
59
|
client_secret=self.client_secret,
|
50
60
|
tenant_id=self.tenant_id,
|
51
|
-
use_default_credentials=
|
61
|
+
use_default_credentials=use_default_creds,
|
52
62
|
timeout=self.timeout,
|
53
63
|
verify_ssl=self.verify_ssl,
|
54
64
|
use_label_cache=self.use_label_cache,
|
55
65
|
label_cache_expiry_minutes=self.label_cache_expiry_minutes,
|
56
66
|
use_cache_first=self.use_cache_first,
|
57
67
|
metadata_cache_dir=self.cache_dir,
|
68
|
+
credential_source=self.credential_source,
|
58
69
|
)
|
59
70
|
|
60
71
|
def validate(self) -> List[str]:
|
@@ -100,6 +111,7 @@ class Profile:
|
|
100
111
|
"client_id": None,
|
101
112
|
"client_secret": None,
|
102
113
|
"tenant_id": None,
|
114
|
+
"use_default_credentials": None,
|
103
115
|
"verify_ssl": True,
|
104
116
|
"timeout": 60,
|
105
117
|
"use_label_cache": True,
|
@@ -108,6 +120,7 @@ class Profile:
|
|
108
120
|
"cache_dir": None,
|
109
121
|
"language": "en-US",
|
110
122
|
"output_format": "table",
|
123
|
+
"credential_source": None,
|
111
124
|
}
|
112
125
|
|
113
126
|
for key, default_value in defaults.items():
|
@@ -119,6 +132,16 @@ class Profile:
|
|
119
132
|
k: v for k, v in migrated_data.items() if k in cls.__dataclass_fields__
|
120
133
|
}
|
121
134
|
|
135
|
+
# Handle credential_source deserialization
|
136
|
+
if "credential_source" in valid_params and valid_params["credential_source"] is not None:
|
137
|
+
from .credential_sources import CredentialSource
|
138
|
+
credential_source_data = valid_params["credential_source"]
|
139
|
+
try:
|
140
|
+
valid_params["credential_source"] = CredentialSource.from_dict(credential_source_data)
|
141
|
+
except Exception as e:
|
142
|
+
logger.error(f"Error deserializing credential_source: {e}")
|
143
|
+
valid_params["credential_source"] = None
|
144
|
+
|
122
145
|
try:
|
123
146
|
return cls(**valid_params)
|
124
147
|
except Exception as e:
|
@@ -151,6 +174,10 @@ class Profile:
|
|
151
174
|
data = asdict(self)
|
152
175
|
data.pop("name", None)
|
153
176
|
|
177
|
+
# Handle credential_source serialization
|
178
|
+
if self.credential_source is not None:
|
179
|
+
data["credential_source"] = self.credential_source.to_dict()
|
180
|
+
|
154
181
|
return data
|
155
182
|
|
156
183
|
def clone(self, name: str, **overrides) -> "Profile":
|
@@ -0,0 +1,181 @@
|
|
1
|
+
"""Enhanced sync models for progress reporting and session management."""
|
2
|
+
|
3
|
+
import uuid
|
4
|
+
from datetime import datetime, timezone
|
5
|
+
from dataclasses import dataclass, field
|
6
|
+
from enum import StrEnum
|
7
|
+
from typing import Dict, List, Optional, Callable, Set
|
8
|
+
|
9
|
+
from .models import SyncStrategy, SyncResult
|
10
|
+
|
11
|
+
|
12
|
+
class SyncStatus(StrEnum):
|
13
|
+
"""Sync operation status"""
|
14
|
+
PENDING = "pending"
|
15
|
+
RUNNING = "running"
|
16
|
+
COMPLETED = "completed"
|
17
|
+
FAILED = "failed"
|
18
|
+
CANCELLED = "cancelled"
|
19
|
+
PAUSED = "paused"
|
20
|
+
|
21
|
+
|
22
|
+
class SyncPhase(StrEnum):
|
23
|
+
"""Detailed sync phases"""
|
24
|
+
INITIALIZING = "initializing"
|
25
|
+
VERSION_CHECK = "version_check"
|
26
|
+
ENTITIES = "entities"
|
27
|
+
SCHEMAS = "schemas"
|
28
|
+
ENUMERATIONS = "enumerations"
|
29
|
+
LABELS = "labels"
|
30
|
+
INDEXING = "indexing"
|
31
|
+
FINALIZING = "finalizing"
|
32
|
+
COMPLETED = "completed"
|
33
|
+
FAILED = "failed"
|
34
|
+
|
35
|
+
|
36
|
+
@dataclass
|
37
|
+
class SyncActivity:
|
38
|
+
"""Individual sync activity within a phase"""
|
39
|
+
name: str
|
40
|
+
status: SyncStatus
|
41
|
+
start_time: Optional[datetime] = None
|
42
|
+
end_time: Optional[datetime] = None
|
43
|
+
progress_percent: float = 0.0
|
44
|
+
items_processed: int = 0
|
45
|
+
items_total: Optional[int] = None
|
46
|
+
current_item: Optional[str] = None
|
47
|
+
error: Optional[str] = None
|
48
|
+
|
49
|
+
def to_dict(self) -> dict:
|
50
|
+
"""Convert to dictionary for JSON serialization"""
|
51
|
+
return {
|
52
|
+
"name": self.name,
|
53
|
+
"status": self.status,
|
54
|
+
"start_time": self.start_time.isoformat() if self.start_time else None,
|
55
|
+
"end_time": self.end_time.isoformat() if self.end_time else None,
|
56
|
+
"progress_percent": self.progress_percent,
|
57
|
+
"items_processed": self.items_processed,
|
58
|
+
"items_total": self.items_total,
|
59
|
+
"current_item": self.current_item,
|
60
|
+
"error": self.error
|
61
|
+
}
|
62
|
+
|
63
|
+
|
64
|
+
@dataclass
|
65
|
+
class SyncSession:
|
66
|
+
"""Complete sync session with detailed tracking"""
|
67
|
+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
68
|
+
global_version_id: int = 0
|
69
|
+
strategy: SyncStrategy = SyncStrategy.FULL
|
70
|
+
status: SyncStatus = SyncStatus.PENDING
|
71
|
+
|
72
|
+
# Overall progress
|
73
|
+
start_time: Optional[datetime] = None
|
74
|
+
end_time: Optional[datetime] = None
|
75
|
+
estimated_completion: Optional[datetime] = None
|
76
|
+
progress_percent: float = 0.0
|
77
|
+
|
78
|
+
# Current state
|
79
|
+
current_phase: SyncPhase = SyncPhase.INITIALIZING
|
80
|
+
current_activity: Optional[str] = None
|
81
|
+
|
82
|
+
# Phase tracking
|
83
|
+
phases: Dict[SyncPhase, SyncActivity] = field(default_factory=dict)
|
84
|
+
|
85
|
+
# Results
|
86
|
+
result: Optional[SyncResult] = None
|
87
|
+
error: Optional[str] = None
|
88
|
+
|
89
|
+
# Metadata
|
90
|
+
initiated_by: str = "system" # user, system, scheduled, mcp
|
91
|
+
can_cancel: bool = True
|
92
|
+
|
93
|
+
# Collected label IDs during sync for efficient label processing
|
94
|
+
collected_label_ids: Set[str] = field(default_factory=set)
|
95
|
+
|
96
|
+
def get_overall_progress(self) -> float:
|
97
|
+
"""Calculate overall progress across all phases"""
|
98
|
+
if not self.phases:
|
99
|
+
return 0.0
|
100
|
+
|
101
|
+
total_weight = len(self.phases)
|
102
|
+
completed_weight = sum(
|
103
|
+
1.0 if activity.status == SyncStatus.COMPLETED
|
104
|
+
else activity.progress_percent / 100.0
|
105
|
+
for activity in self.phases.values()
|
106
|
+
)
|
107
|
+
return min(100.0, (completed_weight / total_weight) * 100.0)
|
108
|
+
|
109
|
+
def get_current_activity_detail(self) -> Optional[SyncActivity]:
|
110
|
+
"""Get current running activity details"""
|
111
|
+
if self.current_phase in self.phases:
|
112
|
+
return self.phases[self.current_phase]
|
113
|
+
return None
|
114
|
+
|
115
|
+
def estimate_remaining_time(self) -> Optional[int]:
|
116
|
+
"""Estimate remaining time in seconds"""
|
117
|
+
if not self.start_time or self.progress_percent <= 0:
|
118
|
+
return None
|
119
|
+
|
120
|
+
elapsed = (datetime.now(timezone.utc) - self.start_time).total_seconds()
|
121
|
+
if self.progress_percent >= 100:
|
122
|
+
return 0
|
123
|
+
|
124
|
+
estimated_total = elapsed / (self.progress_percent / 100.0)
|
125
|
+
return max(0, int(estimated_total - elapsed))
|
126
|
+
|
127
|
+
def to_dict(self) -> dict:
|
128
|
+
"""Convert to dictionary for JSON serialization"""
|
129
|
+
return {
|
130
|
+
"session_id": self.session_id,
|
131
|
+
"global_version_id": self.global_version_id,
|
132
|
+
"strategy": self.strategy,
|
133
|
+
"status": self.status,
|
134
|
+
"start_time": self.start_time.isoformat() if self.start_time else None,
|
135
|
+
"end_time": self.end_time.isoformat() if self.end_time else None,
|
136
|
+
"estimated_completion": self.estimated_completion.isoformat() if self.estimated_completion else None,
|
137
|
+
"progress_percent": self.progress_percent,
|
138
|
+
"current_phase": self.current_phase,
|
139
|
+
"current_activity": self.current_activity,
|
140
|
+
"phases": {
|
141
|
+
phase.value: activity.to_dict()
|
142
|
+
for phase, activity in self.phases.items()
|
143
|
+
},
|
144
|
+
"result": self.result.to_dict() if self.result and hasattr(self.result, 'to_dict') else None,
|
145
|
+
"error": self.error,
|
146
|
+
"initiated_by": self.initiated_by,
|
147
|
+
"can_cancel": self.can_cancel,
|
148
|
+
"estimated_remaining_seconds": self.estimate_remaining_time()
|
149
|
+
}
|
150
|
+
|
151
|
+
|
152
|
+
@dataclass
|
153
|
+
class SyncSessionSummary:
|
154
|
+
"""Lightweight sync session summary for listing"""
|
155
|
+
session_id: str
|
156
|
+
global_version_id: int
|
157
|
+
strategy: SyncStrategy
|
158
|
+
status: SyncStatus
|
159
|
+
start_time: Optional[datetime] = None
|
160
|
+
end_time: Optional[datetime] = None
|
161
|
+
progress_percent: float = 0.0
|
162
|
+
current_phase: SyncPhase = SyncPhase.INITIALIZING
|
163
|
+
current_activity: Optional[str] = None
|
164
|
+
initiated_by: str = "system"
|
165
|
+
duration_seconds: Optional[int] = None
|
166
|
+
|
167
|
+
def to_dict(self) -> dict:
|
168
|
+
"""Convert to dictionary for JSON serialization"""
|
169
|
+
return {
|
170
|
+
"session_id": self.session_id,
|
171
|
+
"global_version_id": self.global_version_id,
|
172
|
+
"strategy": self.strategy,
|
173
|
+
"status": self.status,
|
174
|
+
"start_time": self.start_time.isoformat() if self.start_time else None,
|
175
|
+
"end_time": self.end_time.isoformat() if self.end_time else None,
|
176
|
+
"progress_percent": self.progress_percent,
|
177
|
+
"current_phase": self.current_phase,
|
178
|
+
"current_activity": self.current_activity,
|
179
|
+
"initiated_by": self.initiated_by,
|
180
|
+
"duration_seconds": self.duration_seconds
|
181
|
+
}
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: d365fo-client
|
3
|
-
Version: 0.2.
|
3
|
+
Version: 0.2.3
|
4
4
|
Summary: Microsoft Dynamics 365 Finance & Operations client
|
5
5
|
Author-email: Muhammad Afzaal <mo@thedataguy.pro>
|
6
6
|
License-Expression: MIT
|
@@ -22,6 +22,7 @@ License-File: LICENSE
|
|
22
22
|
Requires-Dist: aiohttp>=3.10.0
|
23
23
|
Requires-Dist: aiofiles>=24.1.0
|
24
24
|
Requires-Dist: azure-identity>=1.19.0
|
25
|
+
Requires-Dist: azure-keyvault-secrets>=4.8.0
|
25
26
|
Requires-Dist: requests>=2.32.0
|
26
27
|
Requires-Dist: aiosqlite>=0.19.0
|
27
28
|
Requires-Dist: cachetools>=6.1.0
|
@@ -50,7 +51,7 @@ A comprehensive Python client library and MCP server for Microsoft Dynamics 365
|
|
50
51
|
- 🏷️ **Label Operations V2**: Multilingual label caching with performance improvements and async support
|
51
52
|
- 🔍 **Advanced Querying**: Support for all OData query parameters ($select, $filter, $expand, etc.)
|
52
53
|
- ⚡ **Action Execution**: Execute bound and unbound OData actions with comprehensive parameter handling
|
53
|
-
- 🔒 **Authentication**: Azure AD integration with default credentials
|
54
|
+
- 🔒 **Authentication**: Azure AD integration with default credentials, service principal, and Azure Key Vault support
|
54
55
|
- 💾 **Intelligent Caching**: Cross-environment cache sharing with module-based version detection
|
55
56
|
- 🌐 **Async/Await**: Modern async/await patterns with optimized session management
|
56
57
|
- 📝 **Type Hints**: Full type annotation support with enhanced data models
|
@@ -58,6 +59,8 @@ A comprehensive Python client library and MCP server for Microsoft Dynamics 365
|
|
58
59
|
- 🖥️ **Comprehensive CLI**: Hierarchical command-line interface for all D365 F&O operations
|
59
60
|
- 🧪 **Multi-tier Testing**: Mock, sandbox, and live integration testing framework (17/17 tests passing)
|
60
61
|
- 📋 **Metadata Scripts**: PowerShell and Python utilities for entity, enumeration, and action discovery
|
62
|
+
- 🔐 **Enhanced Credential Management**: Support for Azure Key Vault and multiple credential sources
|
63
|
+
- 📊 **Advanced Sync Management**: Session-based synchronization with detailed progress tracking
|
61
64
|
|
62
65
|
## Installation
|
63
66
|
|
@@ -73,6 +76,13 @@ uv sync # Installs with exact dependencies from uv.lock
|
|
73
76
|
|
74
77
|
**Note**: The package includes MCP (Model Context Protocol) dependencies by default, enabling AI assistant integration. Both `d365fo-client` CLI and `d365fo-mcp-server` commands will be available after installation.
|
75
78
|
|
79
|
+
**Breaking Change in v0.2.3**: Environment variable names have been updated for consistency:
|
80
|
+
- `AZURE_CLIENT_ID` → `D365FO_CLIENT_ID`
|
81
|
+
- `AZURE_CLIENT_SECRET` → `D365FO_CLIENT_SECRET`
|
82
|
+
- `AZURE_TENANT_ID` → `D365FO_TENANT_ID`
|
83
|
+
|
84
|
+
Please update your environment variables accordingly when upgrading.
|
85
|
+
|
76
86
|
## Quick Start
|
77
87
|
|
78
88
|
## Command Line Interface (CLI)
|
@@ -158,9 +168,9 @@ profiles:
|
|
158
168
|
|
159
169
|
development:
|
160
170
|
base_url: "https://dev.dynamics.com"
|
161
|
-
client_id: "${
|
162
|
-
client_secret: "${
|
163
|
-
tenant_id: "${
|
171
|
+
client_id: "${D365FO_CLIENT_ID}"
|
172
|
+
client_secret: "${D365FO_CLIENT_SECRET}"
|
173
|
+
tenant_id: "${D365FO_TENANT_ID}"
|
164
174
|
use_cache_first: true
|
165
175
|
|
166
176
|
default_profile: "development"
|
@@ -262,7 +272,14 @@ config = FOClientConfig(
|
|
262
272
|
use_default_credentials=False
|
263
273
|
)
|
264
274
|
|
265
|
-
# Option 3:
|
275
|
+
# Option 3: Azure Key Vault integration (New in v0.2.3)
|
276
|
+
config = FOClientConfig(
|
277
|
+
base_url="https://your-fo-environment.dynamics.com",
|
278
|
+
credential_source="keyvault", # Use Azure Key Vault for credentials
|
279
|
+
keyvault_url="https://your-keyvault.vault.azure.net/"
|
280
|
+
)
|
281
|
+
|
282
|
+
# Option 4: With custom settings
|
266
283
|
config = FOClientConfig(
|
267
284
|
base_url="https://your-fo-environment.dynamics.com",
|
268
285
|
use_default_credentials=True,
|
@@ -462,7 +479,9 @@ d365fo-client/
|
|
462
479
|
│ ├── metadata.py # Legacy metadata operations
|
463
480
|
│ ├── metadata_api.py # Metadata API client
|
464
481
|
│ ├── metadata_cache.py # Metadata caching layer V2
|
465
|
-
│ ├── metadata_sync.py # Metadata synchronization V2
|
482
|
+
│ ├── metadata_sync.py # Metadata synchronization V2 with session management
|
483
|
+
│ ├── sync_session.py # Enhanced sync session management (New in v0.2.3)
|
484
|
+
│ ├── credential_manager.py # Credential source management (New in v0.2.3)
|
466
485
|
│ ├── labels.py # Label operations V2
|
467
486
|
│ ├── profiles.py # Profile data models
|
468
487
|
│ ├── profile_manager.py # Profile management
|
@@ -519,6 +538,8 @@ d365fo-client/
|
|
519
538
|
| `client_secret` | str | None | Azure AD client secret |
|
520
539
|
| `tenant_id` | str | None | Azure AD tenant ID |
|
521
540
|
| `use_default_credentials` | bool | True | Use Azure Default Credential |
|
541
|
+
| `credential_source` | str | "environment" | Credential source: "environment", "keyvault" |
|
542
|
+
| `keyvault_url` | str | None | Azure Key Vault URL for credential storage |
|
522
543
|
| `verify_ssl` | bool | False | Verify SSL certificates |
|
523
544
|
| `timeout` | int | 30 | Request timeout in seconds |
|
524
545
|
| `metadata_cache_dir` | str | Platform-specific user cache | Metadata cache directory |
|
@@ -619,9 +640,9 @@ cp tests/integration/.env.template tests/integration/.env
|
|
619
640
|
# Edit .env file with your settings:
|
620
641
|
INTEGRATION_TEST_LEVEL=sandbox
|
621
642
|
D365FO_SANDBOX_BASE_URL=https://your-test.dynamics.com
|
622
|
-
|
623
|
-
|
624
|
-
|
643
|
+
D365FO_CLIENT_ID=your-client-id
|
644
|
+
D365FO_CLIENT_SECRET=your-client-secret
|
645
|
+
D365FO_TENANT_ID=your-tenant-id
|
625
646
|
```
|
626
647
|
|
627
648
|
#### Available Commands
|
@@ -707,9 +728,9 @@ pip install d365fo-client
|
|
707
728
|
|
708
729
|
# Set up environment variables
|
709
730
|
export D365FO_BASE_URL="https://your-environment.dynamics.com"
|
710
|
-
export
|
711
|
-
export
|
712
|
-
export
|
731
|
+
export D365FO_CLIENT_ID="your-client-id" # Optional with default credentials
|
732
|
+
export D365FO_CLIENT_SECRET="your-client-secret" # Optional with default credentials
|
733
|
+
export D365FO_TENANT_ID="your-tenant-id" # Optional with default credentials
|
713
734
|
|
714
735
|
# Start the MCP server
|
715
736
|
d365fo-mcp-server
|
@@ -857,9 +878,19 @@ For service principal authentication:
|
|
857
878
|
|
858
879
|
```bash
|
859
880
|
export D365FO_BASE_URL="https://your-environment.dynamics.com"
|
860
|
-
export
|
861
|
-
export
|
862
|
-
export
|
881
|
+
export D365FO_CLIENT_ID="your-client-id"
|
882
|
+
export D365FO_CLIENT_SECRET="your-client-secret"
|
883
|
+
export D365FO_TENANT_ID="your-tenant-id"
|
884
|
+
d365fo-mcp-server
|
885
|
+
```
|
886
|
+
|
887
|
+
#### Azure Key Vault Integration (New in v0.2.3)
|
888
|
+
For secure credential storage using Azure Key Vault:
|
889
|
+
|
890
|
+
```bash
|
891
|
+
export D365FO_BASE_URL="https://your-environment.dynamics.com"
|
892
|
+
export D365FO_CREDENTIAL_SOURCE="keyvault"
|
893
|
+
export D365FO_KEYVAULT_URL="https://your-keyvault.vault.azure.net/"
|
863
894
|
d365fo-mcp-server
|
864
895
|
```
|
865
896
|
|
@@ -1032,7 +1063,7 @@ tail -f ~/.d365fo-mcp/logs/mcp-server.log
|
|
1032
1063
|
az account show
|
1033
1064
|
|
1034
1065
|
# Test with explicit credentials
|
1035
|
-
export
|
1066
|
+
export D365FO_CLIENT_ID="your-client-id"
|
1036
1067
|
# ... set other variables
|
1037
1068
|
d365fo-mcp-server
|
1038
1069
|
```
|