d365fo-client 0.2.4__py3-none-any.whl → 0.3.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 (58) hide show
  1. d365fo_client/__init__.py +7 -1
  2. d365fo_client/auth.py +9 -21
  3. d365fo_client/cli.py +25 -13
  4. d365fo_client/client.py +8 -4
  5. d365fo_client/config.py +52 -30
  6. d365fo_client/credential_sources.py +5 -0
  7. d365fo_client/main.py +1 -1
  8. d365fo_client/mcp/__init__.py +3 -1
  9. d365fo_client/mcp/auth_server/__init__.py +5 -0
  10. d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
  11. d365fo_client/mcp/auth_server/auth/auth.py +372 -0
  12. d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
  13. d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
  14. d365fo_client/mcp/auth_server/auth/providers/azure.py +325 -0
  15. d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
  16. d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
  17. d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
  18. d365fo_client/mcp/auth_server/dependencies.py +136 -0
  19. d365fo_client/mcp/client_manager.py +16 -67
  20. d365fo_client/mcp/fastmcp_main.py +358 -0
  21. d365fo_client/mcp/fastmcp_server.py +598 -0
  22. d365fo_client/mcp/fastmcp_utils.py +431 -0
  23. d365fo_client/mcp/main.py +40 -13
  24. d365fo_client/mcp/mixins/__init__.py +24 -0
  25. d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
  26. d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
  27. d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
  28. d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
  29. d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
  30. d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
  31. d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
  32. d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
  33. d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
  34. d365fo_client/mcp/prompts/action_execution.py +1 -1
  35. d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
  36. d365fo_client/mcp/tools/crud_tools.py +3 -3
  37. d365fo_client/mcp/tools/sync_tools.py +1 -1
  38. d365fo_client/mcp/utilities/__init__.py +1 -0
  39. d365fo_client/mcp/utilities/auth.py +34 -0
  40. d365fo_client/mcp/utilities/logging.py +58 -0
  41. d365fo_client/mcp/utilities/types.py +426 -0
  42. d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
  43. d365fo_client/metadata_v2/sync_session_manager.py +7 -7
  44. d365fo_client/models.py +139 -139
  45. d365fo_client/output.py +2 -2
  46. d365fo_client/profile_manager.py +62 -27
  47. d365fo_client/profiles.py +118 -113
  48. d365fo_client/settings.py +355 -0
  49. d365fo_client/sync_models.py +85 -2
  50. d365fo_client/utils.py +2 -1
  51. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +273 -18
  52. d365fo_client-0.3.0.dist-info/RECORD +84 -0
  53. d365fo_client-0.3.0.dist-info/entry_points.txt +4 -0
  54. d365fo_client-0.2.4.dist-info/RECORD +0 -56
  55. d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
  56. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
  57. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
  58. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
d365fo_client/models.py CHANGED
@@ -8,7 +8,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
 
11
- from .utils import get_environment_cache_directory
11
+ from .utils import get_default_cache_directory
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from typing import ForwardRef
@@ -60,18 +60,6 @@ class ODataBindingKind(StrEnum):
60
60
  UNBOUND = "Unbound"
61
61
 
62
62
 
63
- class SyncStrategy(StrEnum):
64
- """Metadata synchronization strategies"""
65
-
66
- FULL = "full"
67
- INCREMENTAL = "incremental"
68
- ENTITIES_ONLY = "entities_only"
69
- LABELS_ONLY = "labels_only"
70
- SHARING_MODE = "sharing_mode"
71
- FULL_WITHOUT_LABELS = "full_without_labels"
72
-
73
-
74
-
75
63
  class Cardinality(StrEnum):
76
64
  """Navigation Property Cardinality"""
77
65
 
@@ -81,39 +69,142 @@ class Cardinality(StrEnum):
81
69
 
82
70
  @dataclass
83
71
  class FOClientConfig:
84
- """Configuration for F&O Client"""
72
+ """Configuration for F&O Client
73
+
74
+ This class handles all configuration options for connecting to and interacting
75
+ with Microsoft Dynamics 365 Finance & Operations environments.
85
76
 
77
+ Authentication is handled through credential_source:
78
+ - If None: Uses Azure Default Credentials (DefaultAzureCredential)
79
+ - If provided: Uses the specified credential source (environment vars, Key Vault, etc.)
80
+ """
81
+
82
+ # Core connection settings
86
83
  base_url: str
87
- auth_mode: Optional[str] = "defaut" # default | client_credentials
88
- client_id: Optional[str] = None
89
- client_secret: Optional[str] = None
90
- tenant_id: Optional[str] = None
91
- use_default_credentials: bool = True
92
84
  verify_ssl: bool = False
93
- metadata_cache_dir: str = None
94
85
  timeout: int = 30
95
- # Label cache configuration
96
- use_label_cache: bool = True
97
- label_cache_expiry_minutes: int = 60
98
- # Metadata cache configuration
86
+
87
+ # Authentication - unified through credential source
88
+ credential_source: Optional["CredentialSource"] = None
89
+
90
+ # Cache configuration
91
+ metadata_cache_dir: Optional[str] = None
99
92
  enable_metadata_cache: bool = True
100
- metadata_sync_interval_minutes: int = 60
93
+ use_cache_first: bool = True
101
94
  cache_ttl_seconds: int = 300
102
- enable_fts_search: bool = True
103
95
  max_memory_cache_size: int = 1000
104
- # Cache-first behavior configuration
105
- use_cache_first: bool = True
106
- # Credential source configuration
107
- credential_source: Optional["CredentialSource"] = None
96
+ enable_fts_search: bool = True
97
+
98
+ # Label cache settings
99
+ use_label_cache: bool = True
100
+ label_cache_expiry_minutes: int = 60
101
+
102
+ # Sync configuration
103
+ metadata_sync_interval_minutes: int = 60
104
+ language: str = "en-US"
108
105
 
109
106
  def __post_init__(self):
110
- """Post-initialization to set default cache directory if not provided."""
107
+ """Post-initialization validation and setup."""
108
+ # Set default cache directory if not provided
111
109
  if self.metadata_cache_dir is None:
112
- cache_dir = (
113
- Path.home() / ".d365fo-client"
114
- ) # get_environment_cache_directory(self.base_url)
115
- cache_dir.mkdir(exist_ok=True)
116
- self.metadata_cache_dir = str(cache_dir)
110
+ self.metadata_cache_dir = get_default_cache_directory()
111
+
112
+ # Validate configuration
113
+ self._validate_config()
114
+
115
+ def _validate_config(self) -> None:
116
+ """Validate configuration parameters."""
117
+ if not self.base_url:
118
+ raise ValueError("base_url is required")
119
+
120
+ if not self.base_url.startswith(("http://", "https://")):
121
+ raise ValueError("base_url must start with http:// or https://")
122
+
123
+ if self.timeout <= 0:
124
+ raise ValueError("timeout must be greater than 0")
125
+
126
+ if self.label_cache_expiry_minutes <= 0:
127
+ raise ValueError("label_cache_expiry_minutes must be greater than 0")
128
+
129
+ if self.metadata_sync_interval_minutes <= 0:
130
+ raise ValueError("metadata_sync_interval_minutes must be greater than 0")
131
+
132
+ if self.cache_ttl_seconds <= 0:
133
+ raise ValueError("cache_ttl_seconds must be greater than 0")
134
+
135
+ if self.max_memory_cache_size <= 0:
136
+ raise ValueError("max_memory_cache_size must be greater than 0")
137
+
138
+ @property
139
+ def uses_default_credentials(self) -> bool:
140
+ """Check if using Azure Default Credentials."""
141
+ return self.credential_source is None
142
+
143
+ @property
144
+ def uses_credential_source(self) -> bool:
145
+ """Check if using a specific credential source."""
146
+ return self.credential_source is not None
147
+
148
+ def to_dict(self) -> Dict[str, Any]:
149
+ """Convert to dictionary for serialization."""
150
+ from dataclasses import asdict
151
+ data = asdict(self)
152
+
153
+ # Handle credential_source serialization
154
+ if self.credential_source is not None:
155
+ data["credential_source"] = self.credential_source.to_dict()
156
+
157
+ return data
158
+
159
+ @classmethod
160
+ def from_dict(cls, data: Dict[str, Any]) -> "FOClientConfig":
161
+ """Create from dictionary with validation and migration."""
162
+ # Migrate legacy credential fields to credential_source
163
+ migrated_data = cls._migrate_legacy_credentials(data.copy())
164
+
165
+ # Handle credential_source deserialization
166
+ if "credential_source" in migrated_data and migrated_data["credential_source"] is not None:
167
+ from .credential_sources import CredentialSource
168
+ credential_source_data = migrated_data["credential_source"]
169
+ try:
170
+ migrated_data["credential_source"] = CredentialSource.from_dict(credential_source_data)
171
+ except Exception:
172
+ migrated_data["credential_source"] = None
173
+
174
+ # Filter out unknown and deprecated fields
175
+ valid_fields = {f.name for f in cls.__dataclass_fields__.values()}
176
+ filtered_data = {k: v for k, v in migrated_data.items() if k in valid_fields}
177
+
178
+ return cls(**filtered_data)
179
+
180
+ @classmethod
181
+ def _migrate_legacy_credentials(cls, data: Dict[str, Any]) -> Dict[str, Any]:
182
+ """Migrate legacy credential fields to credential_source."""
183
+ # Check for legacy credential fields
184
+ legacy_fields = ["client_id", "client_secret", "tenant_id", "auth_mode", "use_default_credentials"]
185
+ has_legacy_creds = any(field in data for field in legacy_fields)
186
+
187
+ if has_legacy_creds and "credential_source" not in data:
188
+ # Determine if we should use default credentials
189
+ use_default = data.get("use_default_credentials", True)
190
+ # Check if explicit credentials are provided
191
+ client_id = data.get("client_id")
192
+ client_secret = data.get("client_secret")
193
+ tenant_id = data.get("tenant_id")
194
+
195
+ has_explicit_creds = all([client_id, client_secret, tenant_id])
196
+
197
+ if not use_default and has_explicit_creds:
198
+ # Create environment credential source for backward compatibility
199
+ from .credential_sources import EnvironmentCredentialSource
200
+ data["credential_source"] = EnvironmentCredentialSource().to_dict()
201
+ # If use_default or no explicit creds, credential_source remains None (default creds)
202
+
203
+ # Remove legacy fields
204
+ for field in legacy_fields:
205
+ data.pop(field, None)
206
+
207
+ return data
117
208
 
118
209
 
119
210
  @dataclass
@@ -486,20 +577,20 @@ class ActionTypeInfo:
486
577
  }
487
578
 
488
579
 
489
- @dataclass
490
- class ActionParameterInfo:
491
- """Enhanced action parameter information"""
580
+ # @dataclass
581
+ # class ActionParameterInfo:
582
+ # """Enhanced action parameter information"""
492
583
 
493
- name: str
494
- type: "ActionTypeInfo"
495
- parameter_order: int = 0
584
+ # name: str
585
+ # type: "ActionTypeInfo"
586
+ # parameter_order: int = 0
496
587
 
497
- def to_dict(self) -> Dict[str, Any]:
498
- return {
499
- "name": self.name,
500
- "type": self.type.to_dict(),
501
- "parameter_order": self.parameter_order,
502
- }
588
+ # def to_dict(self) -> Dict[str, Any]:
589
+ # return {
590
+ # "name": self.name,
591
+ # "type": self.type.to_dict(),
592
+ # "parameter_order": self.parameter_order,
593
+ # }
503
594
 
504
595
 
505
596
  @dataclass
@@ -602,21 +693,6 @@ class SearchResults:
602
693
  }
603
694
 
604
695
 
605
- @dataclass
606
- class SyncResult:
607
- """Metadata synchronization result"""
608
-
609
- sync_type: str # full|incremental|skipped
610
- entities_synced: int = 0
611
- actions_synced: int = 0
612
- enumerations_synced: int = 0
613
- labels_synced: int = 0
614
- duration_ms: float = 0.0
615
- success: bool = True
616
- errors: List[str] = field(default_factory=list)
617
- reason: Optional[str] = None
618
-
619
-
620
696
  # ============================================================================
621
697
  # Enhanced V2 Models for Advanced Metadata Caching
622
698
  # ============================================================================
@@ -812,79 +888,3 @@ class VersionDetectionResult:
812
888
  }
813
889
 
814
890
 
815
- @dataclass
816
- class SyncResultV2:
817
- """Enhanced synchronization result for v2 with sharing metrics"""
818
-
819
- sync_type: str # full|incremental|linked|skipped|failed
820
- entities_synced: int = 0
821
- actions_synced: int = 0
822
- enumerations_synced: int = 0
823
- labels_synced: int = 0
824
- duration_ms: float = 0.0
825
- success: bool = True
826
- errors: List[str] = field(default_factory=list)
827
- reason: Optional[str] = None
828
- # V2 specific fields
829
- global_version_id: Optional[int] = None
830
- was_shared: bool = False
831
- reference_count: int = 1
832
- storage_saved_bytes: int = 0
833
-
834
- def to_dict(self) -> Dict[str, Any]:
835
- """Convert to dictionary for JSON serialization"""
836
- return {
837
- "sync_type": self.sync_type,
838
- "entities_synced": self.entities_synced,
839
- "actions_synced": self.actions_synced,
840
- "enumerations_synced": self.enumerations_synced,
841
- "labels_synced": self.labels_synced,
842
- "duration_ms": self.duration_ms,
843
- "success": self.success,
844
- "errors": self.errors,
845
- "reason": self.reason,
846
- "global_version_id": self.global_version_id,
847
- "was_shared": self.was_shared,
848
- "reference_count": self.reference_count,
849
- "storage_saved_bytes": self.storage_saved_bytes,
850
- }
851
-
852
-
853
- @dataclass
854
- class SyncProgress:
855
- """Sync progress tracking"""
856
-
857
- global_version_id: int
858
- strategy: SyncStrategy
859
- phase: str
860
- total_steps: int
861
- completed_steps: int
862
- current_operation: str
863
- start_time: datetime
864
- estimated_completion: Optional[datetime] = None
865
- error: Optional[str] = None
866
-
867
-
868
- @dataclass
869
- class SyncResult:
870
- """Sync operation result"""
871
-
872
- success: bool
873
- error: Optional[str]
874
- duration_ms: int
875
- entity_count: int
876
- action_count: int
877
- enumeration_count: int
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/output.py CHANGED
@@ -163,12 +163,12 @@ class OutputFormatter:
163
163
 
164
164
  def format_success_message(message: str) -> str:
165
165
  """Format a success message with checkmark."""
166
- return f"[OK] {message}"
166
+ return f" {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"[ERROR] {message}"
171
+ return f" {message}"
172
172
 
173
173
 
174
174
  def format_info_message(message: str) -> str:
@@ -66,10 +66,6 @@ class ProfileManager:
66
66
  self,
67
67
  name: str,
68
68
  base_url: str,
69
- auth_mode: str = "default",
70
- client_id: Optional[str] = None,
71
- client_secret: Optional[str] = None,
72
- tenant_id: Optional[str] = None,
73
69
  verify_ssl: bool = True,
74
70
  timeout: int = 60,
75
71
  use_label_cache: bool = True,
@@ -78,26 +74,31 @@ class ProfileManager:
78
74
  language: str = "en-US",
79
75
  cache_dir: Optional[str] = None,
80
76
  description: Optional[str] = None,
81
- credential_source: Optional["CredentialSource"] = None
82
-
77
+ credential_source: Optional["CredentialSource"] = None,
78
+ # Legacy parameters for backward compatibility - will be converted to credential_source
79
+ auth_mode: Optional[str] = None,
80
+ client_id: Optional[str] = None,
81
+ client_secret: Optional[str] = None,
82
+ tenant_id: Optional[str] = None,
83
83
  ) -> bool:
84
84
  """Create a new profile.
85
85
 
86
86
  Args:
87
87
  name: Profile name
88
88
  base_url: D365FO base URL
89
- auth_mode: Authentication mode
90
- client_id: Azure client ID (optional)
91
- client_secret: Azure client secret (optional)
92
- tenant_id: Azure tenant ID (optional)
93
89
  verify_ssl: Whether to verify SSL certificates
94
90
  timeout: Request timeout in seconds
95
91
  use_label_cache: Whether to enable label caching
96
92
  label_cache_expiry_minutes: Label cache expiry in minutes
93
+ use_cache_first: Whether to use cache-first behavior
97
94
  language: Default language code
98
95
  cache_dir: Cache directory path
99
- description: Profile description (stored separately from CLI profile)
100
- credential_source: Credential source configuration
96
+ description: Profile description
97
+ credential_source: Credential source configuration (preferred method)
98
+ auth_mode: [DEPRECATED] Authentication mode - will be converted to credential_source
99
+ client_id: [DEPRECATED] Azure client ID - will be converted to credential_source
100
+ client_secret: [DEPRECATED] Azure client secret - will be converted to credential_source
101
+ tenant_id: [DEPRECATED] Azure tenant ID - will be converted to credential_source
101
102
 
102
103
  Returns:
103
104
  True if created successfully
@@ -108,24 +109,27 @@ class ProfileManager:
108
109
  logger.error(f"Profile already exists: {name}")
109
110
  return False
110
111
 
112
+ # Handle legacy credential parameters by creating credential_source
113
+ effective_credential_source = credential_source
114
+ if effective_credential_source is None and any([auth_mode, client_id, client_secret, tenant_id]):
115
+ effective_credential_source = self._create_credential_source_from_legacy(
116
+ auth_mode, client_id, client_secret, tenant_id
117
+ )
118
+
111
119
  # Create unified profile
112
120
  profile = Profile(
113
121
  name=name,
114
122
  base_url=base_url,
115
- auth_mode=auth_mode,
116
- client_id=client_id,
117
- client_secret=client_secret,
118
- tenant_id=tenant_id,
119
123
  verify_ssl=verify_ssl,
120
124
  timeout=timeout,
121
125
  use_label_cache=use_label_cache,
122
126
  label_cache_expiry_minutes=label_cache_expiry_minutes,
123
127
  use_cache_first=use_cache_first,
124
128
  language=language,
125
- cache_dir=cache_dir,
129
+ metadata_cache_dir=cache_dir, # Map cache_dir parameter to metadata_cache_dir field
126
130
  description=description,
127
131
  output_format="table", # Default for CLI compatibility
128
- credential_source=credential_source
132
+ credential_source=effective_credential_source
129
133
  )
130
134
 
131
135
  self.config_manager.save_profile(profile)
@@ -293,7 +297,7 @@ class ProfileManager:
293
297
  export_data = {"version": "1.0", "profiles": {}}
294
298
 
295
299
  for name, profile in profiles.items():
296
- export_data["profiles"][name] = asdict(profile)
300
+ export_data["profiles"][name] = profile.to_dict()
297
301
 
298
302
  with open(file_path, "w", encoding="utf-8") as f:
299
303
  yaml.dump(export_data, f, default_flow_style=False, allow_unicode=True)
@@ -339,14 +343,13 @@ class ProfileManager:
339
343
  if overwrite and self.get_profile(name):
340
344
  self.delete_profile(name)
341
345
 
342
- # Extract description if present
343
- description = profile_data.pop("description", None)
344
-
345
- # Create profile
346
- success = self.create_profile(
347
- description=description, **profile_data
348
- )
349
- results[name] = success
346
+ # Create profile directly using Profile.create_from_dict
347
+ # This handles all fields including credential_source properly
348
+ profile = Profile.create_from_dict(name, profile_data)
349
+
350
+ # Save the profile directly via config manager
351
+ self.config_manager.save_profile(profile)
352
+ results[name] = True
350
353
 
351
354
  except Exception as e:
352
355
  logger.error(f"Error importing profile {name}: {e}")
@@ -358,3 +361,35 @@ class ProfileManager:
358
361
  except Exception as e:
359
362
  logger.error(f"Error importing profiles from {file_path}: {e}")
360
363
  return results
364
+
365
+ def _create_credential_source_from_legacy(
366
+ self,
367
+ auth_mode: Optional[str],
368
+ client_id: Optional[str],
369
+ client_secret: Optional[str],
370
+ tenant_id: Optional[str]
371
+ ) -> Optional["CredentialSource"]:
372
+ """Create credential source from legacy credential parameters.
373
+
374
+ Args:
375
+ auth_mode: Legacy authentication mode
376
+ client_id: Legacy client ID
377
+ client_secret: Legacy client secret
378
+ tenant_id: Legacy tenant ID
379
+
380
+ Returns:
381
+ CredentialSource instance or None for default credentials
382
+ """
383
+ # If auth_mode is explicitly "default" or no credentials provided, use default credentials
384
+ if auth_mode == "default" or not any([client_id, client_secret, tenant_id]):
385
+ return None
386
+
387
+ # If we have explicit credentials, create environment credential source
388
+ if all([client_id, client_secret, tenant_id]):
389
+ from .credential_sources import EnvironmentCredentialSource
390
+ logger.info("Converting legacy credentials to environment credential source")
391
+ return EnvironmentCredentialSource()
392
+
393
+ # If we have partial credentials, log warning and use default
394
+ logger.warning("Incomplete legacy credentials provided, using default credentials")
395
+ return None