d365fo-client 0.1.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 (51) hide show
  1. d365fo_client/__init__.py +305 -0
  2. d365fo_client/auth.py +93 -0
  3. d365fo_client/cli.py +700 -0
  4. d365fo_client/client.py +1454 -0
  5. d365fo_client/config.py +304 -0
  6. d365fo_client/crud.py +200 -0
  7. d365fo_client/exceptions.py +49 -0
  8. d365fo_client/labels.py +528 -0
  9. d365fo_client/main.py +502 -0
  10. d365fo_client/mcp/__init__.py +16 -0
  11. d365fo_client/mcp/client_manager.py +276 -0
  12. d365fo_client/mcp/main.py +98 -0
  13. d365fo_client/mcp/models.py +371 -0
  14. d365fo_client/mcp/prompts/__init__.py +43 -0
  15. d365fo_client/mcp/prompts/action_execution.py +480 -0
  16. d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
  17. d365fo_client/mcp/resources/__init__.py +15 -0
  18. d365fo_client/mcp/resources/database_handler.py +555 -0
  19. d365fo_client/mcp/resources/entity_handler.py +176 -0
  20. d365fo_client/mcp/resources/environment_handler.py +132 -0
  21. d365fo_client/mcp/resources/metadata_handler.py +283 -0
  22. d365fo_client/mcp/resources/query_handler.py +135 -0
  23. d365fo_client/mcp/server.py +432 -0
  24. d365fo_client/mcp/tools/__init__.py +17 -0
  25. d365fo_client/mcp/tools/connection_tools.py +175 -0
  26. d365fo_client/mcp/tools/crud_tools.py +579 -0
  27. d365fo_client/mcp/tools/database_tools.py +813 -0
  28. d365fo_client/mcp/tools/label_tools.py +189 -0
  29. d365fo_client/mcp/tools/metadata_tools.py +766 -0
  30. d365fo_client/mcp/tools/profile_tools.py +706 -0
  31. d365fo_client/metadata_api.py +793 -0
  32. d365fo_client/metadata_v2/__init__.py +59 -0
  33. d365fo_client/metadata_v2/cache_v2.py +1372 -0
  34. d365fo_client/metadata_v2/database_v2.py +585 -0
  35. d365fo_client/metadata_v2/global_version_manager.py +573 -0
  36. d365fo_client/metadata_v2/search_engine_v2.py +423 -0
  37. d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
  38. d365fo_client/metadata_v2/version_detector.py +439 -0
  39. d365fo_client/models.py +862 -0
  40. d365fo_client/output.py +181 -0
  41. d365fo_client/profile_manager.py +342 -0
  42. d365fo_client/profiles.py +178 -0
  43. d365fo_client/query.py +162 -0
  44. d365fo_client/session.py +60 -0
  45. d365fo_client/utils.py +196 -0
  46. d365fo_client-0.1.0.dist-info/METADATA +1084 -0
  47. d365fo_client-0.1.0.dist-info/RECORD +51 -0
  48. d365fo_client-0.1.0.dist-info/WHEEL +5 -0
  49. d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
  50. d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,304 @@
1
+ """Configuration management for d365fo-client CLI."""
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+
10
+ import yaml
11
+
12
+ from .models import FOClientConfig
13
+ from .profiles import Profile
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ # Legacy alias for backward compatibility
19
+ CLIProfile = Profile
20
+
21
+
22
+ class ConfigManager:
23
+ """Manages configuration profiles and settings."""
24
+
25
+ def __init__(self, config_path: Optional[str] = None):
26
+ """Initialize configuration manager.
27
+
28
+ Args:
29
+ config_path: Path to configuration file. If None, uses default.
30
+ """
31
+ self.config_path = config_path or self._get_default_config_path()
32
+ self._config_data = self._load_config()
33
+
34
+ def _get_default_config_path(self) -> str:
35
+ """Get the default configuration file path."""
36
+ config_dir = Path.home() / ".d365fo-client"
37
+ config_dir.mkdir(exist_ok=True)
38
+ return str(config_dir / "config.yaml")
39
+
40
+ def _load_config(self) -> Dict[str, Any]:
41
+ """Load configuration from file."""
42
+ if not os.path.exists(self.config_path):
43
+ return {"profiles": {}, "default_profile": None, "global": {}}
44
+
45
+ try:
46
+ with open(self.config_path, "r", encoding="utf-8") as f:
47
+ config = yaml.safe_load(f) or {}
48
+
49
+ # Ensure required sections exist
50
+ if "profiles" not in config:
51
+ config["profiles"] = {}
52
+ if "global" not in config:
53
+ config["global"] = {}
54
+
55
+ return config
56
+ except Exception as e:
57
+ # If config file is corrupted, return empty config
58
+ print(f"Warning: Could not load config file {self.config_path}: {e}")
59
+ return {"profiles": {}, "default_profile": None, "global": {}}
60
+
61
+ def _save_config(self) -> None:
62
+ """Save configuration to file."""
63
+ try:
64
+ os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
65
+ with open(self.config_path, "w", encoding="utf-8") as f:
66
+ yaml.dump(
67
+ self._config_data, f, default_flow_style=False, allow_unicode=True
68
+ )
69
+ except Exception as e:
70
+ print(f"Error saving config file {self.config_path}: {e}")
71
+
72
+ def get_profile(self, profile_name: str) -> Optional[Profile]:
73
+ """Get a specific configuration profile.
74
+
75
+ Args:
76
+ profile_name: Name of the profile to retrieve
77
+
78
+ Returns:
79
+ Profile instance or None if not found
80
+ """
81
+ profiles = self._config_data.get("profiles", {})
82
+ if profile_name not in profiles:
83
+ return None
84
+
85
+ profile_data = profiles[profile_name]
86
+ try:
87
+ return Profile.from_dict(profile_name, profile_data)
88
+ except Exception as e:
89
+ logger.error(f"Error loading profile {profile_name}: {e}")
90
+ return None
91
+
92
+ def save_profile(self, profile: Profile) -> None:
93
+ """Save a configuration profile.
94
+
95
+ Args:
96
+ profile: Profile to save
97
+ """
98
+ if "profiles" not in self._config_data:
99
+ self._config_data["profiles"] = {}
100
+
101
+ # Convert profile to dict for storage
102
+ self._config_data["profiles"][profile.name] = profile.to_dict()
103
+ self._save_config()
104
+
105
+ def delete_profile(self, profile_name: str) -> bool:
106
+ """Delete a configuration profile.
107
+
108
+ Args:
109
+ profile_name: Name of the profile to delete
110
+
111
+ Returns:
112
+ True if deleted, False if not found
113
+ """
114
+ profiles = self._config_data.get("profiles", {})
115
+ if profile_name not in profiles:
116
+ return False
117
+
118
+ del profiles[profile_name]
119
+
120
+ # Clear default if it was the deleted profile
121
+ if self._config_data.get("default_profile") == profile_name:
122
+ self._config_data["default_profile"] = None
123
+
124
+ self._save_config()
125
+ return True
126
+
127
+ def list_profiles(self) -> Dict[str, Profile]:
128
+ """List all configuration profiles.
129
+
130
+ Returns:
131
+ Dictionary of profile name to Profile instances
132
+ """
133
+ profiles = {}
134
+ for name, data in self._config_data.get("profiles", {}).items():
135
+ try:
136
+ profiles[name] = Profile.from_dict(name, data)
137
+ except Exception as e:
138
+ logger.error(f"Error loading profile {name}: {e}")
139
+ continue
140
+ return profiles
141
+
142
+ def set_default_profile(self, profile_name: str) -> bool:
143
+ """Set the default profile.
144
+
145
+ Args:
146
+ profile_name: Name of the profile to set as default
147
+
148
+ Returns:
149
+ True if set, False if profile doesn't exist
150
+ """
151
+ profiles = self._config_data.get("profiles", {})
152
+ if profile_name not in profiles:
153
+ return False
154
+
155
+ self._config_data["default_profile"] = profile_name
156
+ self._save_config()
157
+ return True
158
+
159
+ def get_default_profile(self) -> Optional[Profile]:
160
+ """Get the default configuration profile.
161
+
162
+ Returns:
163
+ Default Profile instance or None if not set
164
+ """
165
+ default_name = self._config_data.get("default_profile")
166
+ if not default_name:
167
+ return None
168
+
169
+ return self.get_profile(default_name)
170
+
171
+ def get_effective_config(self, args: argparse.Namespace) -> FOClientConfig:
172
+ """Build effective configuration from args, environment, and profiles.
173
+
174
+ Precedence: Command line args > Environment variables > Profile config > Defaults
175
+
176
+ Args:
177
+ args: Parsed command line arguments
178
+
179
+ Returns:
180
+ FOClientConfig instance
181
+ """
182
+ # Start with defaults
183
+ config_params = {
184
+ "base_url": None,
185
+ "use_default_credentials": True,
186
+ "client_id": None,
187
+ "client_secret": None,
188
+ "tenant_id": None,
189
+ "verify_ssl": True,
190
+ "use_label_cache": True,
191
+ "label_cache_expiry_minutes": 60,
192
+ "use_cache_first": True,
193
+ "timeout": 60,
194
+ }
195
+
196
+ # Apply profile settings if specified
197
+ profile = None
198
+ profile_name = getattr(args, "profile", None)
199
+ if profile_name:
200
+ profile = self.get_profile(profile_name)
201
+ elif not profile_name:
202
+ # Try default profile
203
+ profile = self.get_default_profile()
204
+
205
+ if profile:
206
+ config_params.update(
207
+ {
208
+ "base_url": profile.base_url,
209
+ "client_id": profile.client_id,
210
+ "client_secret": profile.client_secret,
211
+ "tenant_id": profile.tenant_id,
212
+ "verify_ssl": profile.verify_ssl,
213
+ "use_label_cache": profile.use_label_cache,
214
+ "label_cache_expiry_minutes": profile.label_cache_expiry_minutes,
215
+ "use_cache_first": profile.use_cache_first,
216
+ "timeout": profile.timeout,
217
+ }
218
+ )
219
+
220
+ # Apply environment variables
221
+ env_mappings = {
222
+ "D365FO_BASE_URL": "base_url",
223
+ "D365FO_CLIENT_ID": "client_id",
224
+ "D365FO_CLIENT_SECRET": "client_secret",
225
+ "D365FO_TENANT_ID": "tenant_id",
226
+ "D365FO_VERIFY_SSL": "verify_ssl",
227
+ "D365FO_LABEL_CACHE": "use_label_cache",
228
+ "D365FO_LABEL_EXPIRY": "label_cache_expiry_minutes",
229
+ "D365FO_USE_CACHE_FIRST": "use_cache_first",
230
+ "D365FO_TIMEOUT": "timeout",
231
+ }
232
+
233
+ for env_var, param_name in env_mappings.items():
234
+ env_value = os.getenv(env_var)
235
+ if env_value:
236
+ if param_name in ["verify_ssl", "use_label_cache", "use_cache_first"]:
237
+ # Convert to boolean
238
+ config_params[param_name] = env_value.lower() in (
239
+ "true",
240
+ "1",
241
+ "yes",
242
+ "on",
243
+ )
244
+ elif param_name in ["label_cache_expiry_minutes", "timeout"]:
245
+ # Convert to int
246
+ try:
247
+ config_params[param_name] = int(env_value)
248
+ except ValueError:
249
+ pass # Keep default/profile value
250
+ else:
251
+ config_params[param_name] = env_value
252
+
253
+ # Apply command line arguments (highest precedence)
254
+ arg_mappings = {
255
+ "base_url": "base_url",
256
+ "client_id": "client_id",
257
+ "client_secret": "client_secret",
258
+ "tenant_id": "tenant_id",
259
+ "verify_ssl": "verify_ssl",
260
+ "label_cache": "use_label_cache",
261
+ "label_expiry": "label_cache_expiry_minutes",
262
+ "use_cache_first": "use_cache_first",
263
+ "timeout": "timeout",
264
+ }
265
+
266
+ for arg_name, param_name in arg_mappings.items():
267
+ arg_value = getattr(args, arg_name, None)
268
+ if arg_value is not None:
269
+ config_params[param_name] = arg_value
270
+
271
+ # Determine authentication mode
272
+ if any(
273
+ [
274
+ config_params["client_id"],
275
+ config_params["client_secret"],
276
+ config_params["tenant_id"],
277
+ ]
278
+ ):
279
+ config_params["use_default_credentials"] = False
280
+
281
+ return FOClientConfig(**config_params)
282
+
283
+ def _substitute_env_variables(self, value: Any) -> Any:
284
+ """Substitute environment variables in configuration values.
285
+
286
+ Supports ${VAR_NAME} syntax.
287
+
288
+ Args:
289
+ value: Value to process
290
+
291
+ Returns:
292
+ Value with environment variables substituted
293
+ """
294
+ if not isinstance(value, str):
295
+ return value
296
+
297
+ # Simple environment variable substitution
298
+ import re
299
+
300
+ def replace_var(match):
301
+ var_name = match.group(1)
302
+ return os.getenv(var_name, match.group(0)) # Return original if not found
303
+
304
+ return re.sub(r"\$\{([^}]+)\}", replace_var, value)
d365fo_client/crud.py ADDED
@@ -0,0 +1,200 @@
1
+ """CRUD operations for D365 F&O client."""
2
+
3
+ from typing import Any, Dict, List, Optional, Union
4
+
5
+ import aiohttp
6
+
7
+ from .models import QueryOptions
8
+ from .query import QueryBuilder
9
+ from .session import SessionManager
10
+
11
+
12
+ class CrudOperations:
13
+ """Handles CRUD operations for F&O entities"""
14
+
15
+ def __init__(self, session_manager: SessionManager, base_url: str):
16
+ """Initialize CRUD operations
17
+
18
+ Args:
19
+ session_manager: HTTP session manager
20
+ base_url: Base F&O URL
21
+ """
22
+ self.session_manager = session_manager
23
+ self.base_url = base_url
24
+
25
+ async def get_entities(
26
+ self, entity_name: str, options: Optional[QueryOptions] = None
27
+ ) -> Dict[str, Any]:
28
+ """Get entities with OData query options
29
+
30
+ Args:
31
+ entity_name: Name of the entity set
32
+ options: OData query options
33
+
34
+ Returns:
35
+ Response containing entities
36
+ """
37
+ session = await self.session_manager.get_session()
38
+ query_string = QueryBuilder.build_query_string(options)
39
+ url = f"{self.base_url}/data/{entity_name}{query_string}"
40
+
41
+ async with session.get(url) as response:
42
+ if response.status == 200:
43
+ return await response.json()
44
+ else:
45
+ error_text = await response.text()
46
+ raise Exception(
47
+ f"GET {entity_name} failed: {response.status} - {error_text}"
48
+ )
49
+
50
+ async def get_entity(
51
+ self,
52
+ entity_name: str,
53
+ key: Union[str, Dict[str, Any]],
54
+ options: Optional[QueryOptions] = None,
55
+ ) -> Dict[str, Any]:
56
+ """Get single entity by key
57
+
58
+ Args:
59
+ entity_name: Name of the entity set
60
+ key: Entity key value (string for simple keys, dict for composite keys)
61
+ options: OData query options
62
+
63
+ Returns:
64
+ Entity data
65
+ """
66
+ session = await self.session_manager.get_session()
67
+ query_string = QueryBuilder.build_query_string(options)
68
+ url = QueryBuilder.build_entity_url(self.base_url, entity_name, key)
69
+ url += query_string
70
+
71
+ async with session.get(url) as response:
72
+ if response.status == 200:
73
+ return await response.json()
74
+ else:
75
+ error_text = await response.text()
76
+ raise Exception(
77
+ f"GET {entity_name}({key}) failed: {response.status} - {error_text}"
78
+ )
79
+
80
+ async def create_entity(
81
+ self, entity_name: str, data: Dict[str, Any]
82
+ ) -> Dict[str, Any]:
83
+ """Create new entity
84
+
85
+ Args:
86
+ entity_name: Name of the entity set
87
+ data: Entity data to create
88
+
89
+ Returns:
90
+ Created entity data
91
+ """
92
+ session = await self.session_manager.get_session()
93
+ url = f"{self.base_url}/data/{entity_name}"
94
+
95
+ async with session.post(url, json=data) as response:
96
+ if response.status in [200, 201]:
97
+ return await response.json()
98
+ else:
99
+ error_text = await response.text()
100
+ raise Exception(
101
+ f"CREATE {entity_name} failed: {response.status} - {error_text}"
102
+ )
103
+
104
+ async def update_entity(
105
+ self,
106
+ entity_name: str,
107
+ key: Union[str, Dict[str, Any]],
108
+ data: Dict[str, Any],
109
+ method: str = "PATCH",
110
+ ) -> Dict[str, Any]:
111
+ """Update existing entity
112
+
113
+ Args:
114
+ entity_name: Name of the entity set
115
+ key: Entity key value (string for simple keys, dict for composite keys)
116
+ data: Updated entity data
117
+ method: HTTP method (PATCH or PUT)
118
+
119
+ Returns:
120
+ Updated entity data
121
+ """
122
+ session = await self.session_manager.get_session()
123
+ url = QueryBuilder.build_entity_url(self.base_url, entity_name, key)
124
+
125
+ async with session.request(method, url, json=data) as response:
126
+ if response.status in [200, 204]:
127
+ if response.status == 204:
128
+ return {"success": True}
129
+ return await response.json()
130
+ else:
131
+ error_text = await response.text()
132
+ raise Exception(
133
+ f"{method} {entity_name}({key}) failed: {response.status} - {error_text}"
134
+ )
135
+
136
+ async def delete_entity(
137
+ self, entity_name: str, key: Union[str, Dict[str, Any]]
138
+ ) -> bool:
139
+ """Delete entity
140
+
141
+ Args:
142
+ entity_name: Name of the entity set
143
+ key: Entity key value (string for simple keys, dict for composite keys)
144
+
145
+ Returns:
146
+ True if successful
147
+ """
148
+ session = await self.session_manager.get_session()
149
+ url = QueryBuilder.build_entity_url(self.base_url, entity_name, key)
150
+
151
+ async with session.delete(url) as response:
152
+ if response.status in [200, 204]:
153
+ return True
154
+ else:
155
+ error_text = await response.text()
156
+ raise Exception(
157
+ f"DELETE {entity_name}({key}) failed: {response.status} - {error_text}"
158
+ )
159
+
160
+ async def call_action(
161
+ self,
162
+ action_name: str,
163
+ parameters: Optional[Dict[str, Any]] = None,
164
+ entity_name: Optional[str] = None,
165
+ entity_key: Optional[Union[str, Dict[str, Any]]] = None,
166
+ ) -> Any:
167
+ """Call OData action method
168
+
169
+ Args:
170
+ action_name: Name of the action
171
+ parameters: Action parameters
172
+ entity_name: Entity name for bound actions
173
+ entity_key: Entity key for bound actions (string for simple keys, dict for composite keys)
174
+
175
+ Returns:
176
+ Action result
177
+ """
178
+ session = await self.session_manager.get_session()
179
+ url = QueryBuilder.build_action_url(
180
+ self.base_url, action_name, entity_name, entity_key
181
+ )
182
+
183
+ # Prepare request body
184
+ body = parameters or {}
185
+
186
+ async with session.post(url, json=body) as response:
187
+ if response.status in [200, 201, 204]:
188
+ if response.status == 204:
189
+ return {"success": True}
190
+
191
+ content_type = response.headers.get("content-type", "")
192
+ if "application/json" in content_type:
193
+ return await response.json()
194
+ else:
195
+ return await response.text()
196
+ else:
197
+ error_text = await response.text()
198
+ raise Exception(
199
+ f"Action {action_name} failed: {response.status} - {error_text}"
200
+ )
@@ -0,0 +1,49 @@
1
+ """Exception classes for D365 F&O client."""
2
+
3
+
4
+ class FOClientError(Exception):
5
+ """Base exception for F&O client errors"""
6
+
7
+ pass
8
+
9
+
10
+ class AuthenticationError(FOClientError):
11
+ """Authentication related errors"""
12
+
13
+ pass
14
+
15
+
16
+ class MetadataError(FOClientError):
17
+ """Metadata operation errors"""
18
+
19
+ pass
20
+
21
+
22
+ class EntityError(FOClientError):
23
+ """Entity operation errors"""
24
+
25
+ pass
26
+
27
+
28
+ class ActionError(FOClientError):
29
+ """Action execution errors"""
30
+
31
+ pass
32
+
33
+
34
+ class LabelError(FOClientError):
35
+ """Label operation errors"""
36
+
37
+ pass
38
+
39
+
40
+ class ConfigurationError(FOClientError):
41
+ """Configuration related errors"""
42
+
43
+ pass
44
+
45
+
46
+ class NetworkError(FOClientError):
47
+ """Network and HTTP related errors"""
48
+
49
+ pass