d365fo-client 0.2.2__py3-none-any.whl → 0.2.4__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/auth.py +48 -9
- d365fo_client/client.py +40 -20
- 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/server.py +69 -22
- d365fo_client/mcp/tools/__init__.py +2 -0
- d365fo_client/mcp/tools/profile_tools.py +261 -2
- d365fo_client/mcp/tools/sync_tools.py +503 -0
- d365fo_client/metadata_api.py +67 -0
- d365fo_client/metadata_v2/cache_v2.py +11 -9
- d365fo_client/metadata_v2/global_version_manager.py +2 -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 +22 -3
- d365fo_client/profile_manager.py +7 -1
- d365fo_client/profiles.py +28 -1
- d365fo_client/sync_models.py +181 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/METADATA +1011 -784
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/RECORD +24 -20
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/top_level.txt +0 -0
d365fo_client/models.py
CHANGED
@@ -12,6 +12,7 @@ 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):
|
@@ -65,7 +66,10 @@ class SyncStrategy(StrEnum):
|
|
65
66
|
FULL = "full"
|
66
67
|
INCREMENTAL = "incremental"
|
67
68
|
ENTITIES_ONLY = "entities_only"
|
69
|
+
LABELS_ONLY = "labels_only"
|
68
70
|
SHARING_MODE = "sharing_mode"
|
71
|
+
FULL_WITHOUT_LABELS = "full_without_labels"
|
72
|
+
|
69
73
|
|
70
74
|
|
71
75
|
class Cardinality(StrEnum):
|
@@ -80,6 +84,7 @@ class FOClientConfig:
|
|
80
84
|
"""Configuration for F&O Client"""
|
81
85
|
|
82
86
|
base_url: str
|
87
|
+
auth_mode: Optional[str] = "defaut" # default | client_credentials
|
83
88
|
client_id: Optional[str] = None
|
84
89
|
client_secret: Optional[str] = None
|
85
90
|
tenant_id: Optional[str] = None
|
@@ -98,6 +103,8 @@ class FOClientConfig:
|
|
98
103
|
max_memory_cache_size: int = 1000
|
99
104
|
# Cache-first behavior configuration
|
100
105
|
use_cache_first: bool = True
|
106
|
+
# Credential source configuration
|
107
|
+
credential_source: Optional["CredentialSource"] = None
|
101
108
|
|
102
109
|
def __post_init__(self):
|
103
110
|
"""Post-initialization to set default cache directory if not provided."""
|
@@ -738,9 +745,9 @@ class GlobalVersionInfo:
|
|
738
745
|
first_seen_at: datetime
|
739
746
|
last_used_at: datetime
|
740
747
|
reference_count: int
|
741
|
-
|
748
|
+
modules: List[ModuleVersionInfo] = field(
|
742
749
|
default_factory=list
|
743
|
-
) #
|
750
|
+
) # Modules for debugging
|
744
751
|
|
745
752
|
def to_dict(self) -> Dict[str, Any]:
|
746
753
|
"""Convert to dictionary for JSON serialization"""
|
@@ -751,7 +758,7 @@ class GlobalVersionInfo:
|
|
751
758
|
"first_seen_at": self.first_seen_at.isoformat(),
|
752
759
|
"last_used_at": self.last_used_at.isoformat(),
|
753
760
|
"reference_count": self.reference_count,
|
754
|
-
"
|
761
|
+
"modules": [module.to_dict() for module in self.modules],
|
755
762
|
}
|
756
763
|
|
757
764
|
|
@@ -869,3 +876,15 @@ class SyncResult:
|
|
869
876
|
action_count: int
|
870
877
|
enumeration_count: int
|
871
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
|
+
}
|