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/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
- sample_modules: List[ModuleVersionInfo] = field(
748
+ modules: List[ModuleVersionInfo] = field(
742
749
  default_factory=list
743
- ) # Sample for debugging
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
- "sample_modules": [module.to_dict() for module in self.sample_modules],
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
+ }
@@ -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=self.auth_mode == "default",
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
+ }