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
d365fo_client/query.py ADDED
@@ -0,0 +1,162 @@
1
+ """OData query utilities for D365 F&O client."""
2
+
3
+ from typing import Any, Dict, Optional, Union
4
+ from urllib.parse import quote, urlencode
5
+
6
+ from .models import QueryOptions
7
+
8
+
9
+ class QueryBuilder:
10
+ """Utility class for building OData queries"""
11
+
12
+ @staticmethod
13
+ def build_query_string(options: Optional[QueryOptions] = None) -> str:
14
+ """Build OData query string from options
15
+
16
+ Args:
17
+ options: Query options to convert
18
+
19
+ Returns:
20
+ URL query string (with leading ? if parameters exist)
21
+ """
22
+ if not options:
23
+ return ""
24
+
25
+ params = QueryBuilder.build_query_params(options)
26
+
27
+ if params:
28
+ return "?" + urlencode(params, quote_via=quote)
29
+ return ""
30
+
31
+ @staticmethod
32
+ def build_query_params(options: Optional[QueryOptions] = None) -> dict:
33
+ """Build OData query parameters dict from options
34
+
35
+ Args:
36
+ options: Query options to convert
37
+
38
+ Returns:
39
+ Dictionary of query parameters
40
+ """
41
+ if not options:
42
+ return {}
43
+
44
+ params = {}
45
+
46
+ if options.select:
47
+ params["$select"] = ",".join(options.select)
48
+
49
+ if options.filter:
50
+ params["$filter"] = options.filter
51
+
52
+ if options.expand:
53
+ params["$expand"] = ",".join(options.expand)
54
+
55
+ if options.orderby:
56
+ params["$orderby"] = ",".join(options.orderby)
57
+
58
+ if options.top is not None:
59
+ params["$top"] = str(options.top)
60
+
61
+ if options.skip is not None:
62
+ params["$skip"] = str(options.skip)
63
+
64
+ if options.count:
65
+ params["$count"] = "true"
66
+
67
+ if options.search:
68
+ params["$search"] = options.search
69
+
70
+ return params
71
+
72
+ @staticmethod
73
+ def encode_key(key: Union[str, Dict[str, Any]]) -> str:
74
+ """Encode entity key for URL
75
+
76
+ Args:
77
+ key: Entity key value (string for simple keys, dict for composite keys)
78
+
79
+ Returns:
80
+ URL-encoded key
81
+ """
82
+ if isinstance(key, dict):
83
+ # Format composite key: key1=value1,key2=value2
84
+ key_parts = []
85
+ for key_name, key_value in key.items():
86
+ encoded_value = quote(str(key_value), safe="")
87
+ key_parts.append(f"{key_name}='{encoded_value}'")
88
+ return ",".join(key_parts)
89
+ else:
90
+ # Simple string key
91
+ return quote(str(key), safe="")
92
+
93
+ @staticmethod
94
+ def build_entity_url(
95
+ base_url: str,
96
+ entity_name: str,
97
+ key: Optional[Union[str, Dict[str, Any]]] = None,
98
+ ) -> str:
99
+ """Build entity URL
100
+
101
+ Args:
102
+ base_url: Base F&O URL
103
+ entity_name: Entity set name
104
+ key: Optional entity key (string for simple keys, dict for composite keys)
105
+
106
+ Returns:
107
+ Complete entity URL
108
+ """
109
+ base = f"{base_url.rstrip('/')}/data/{entity_name}"
110
+ if key:
111
+ encoded_key = QueryBuilder.encode_key(key)
112
+ if isinstance(key, dict):
113
+ # For composite keys, don't wrap in additional quotes
114
+ return f"{base}({encoded_key})"
115
+ else:
116
+ # For simple string keys, wrap in quotes
117
+ return f"{base}('{encoded_key}')"
118
+ return base
119
+
120
+ @staticmethod
121
+ def build_action_url(
122
+ base_url: str,
123
+ action_name: str,
124
+ entity_name: Optional[str] = None,
125
+ entity_key: Optional[Union[str, Dict[str, Any]]] = None,
126
+ ) -> str:
127
+ """Build action URL
128
+
129
+ Args:
130
+ base_url: Base F&O URL
131
+ action_name: Action name
132
+ entity_name: Optional entity name for bound actions
133
+ entity_key: Optional entity key for bound actions (string for simple keys, dict for composite keys)
134
+
135
+ Returns:
136
+ Complete action URL
137
+ """
138
+ base = base_url.rstrip("/")
139
+
140
+ # Ensure action_name is properly prefixed
141
+ if action_name.startswith("/Microsoft.Dynamics.DataEntities."):
142
+ action_path = action_name
143
+ elif action_name.startswith("Microsoft.Dynamics.DataEntities."):
144
+ action_path = "/" + action_name
145
+ else:
146
+ action_path = "/Microsoft.Dynamics.DataEntities." + action_name
147
+
148
+ if entity_name and entity_key:
149
+ # Bound action on specific entity
150
+ encoded_key = QueryBuilder.encode_key(entity_key)
151
+ if isinstance(entity_key, dict):
152
+ # For composite keys, don't wrap in additional quotes
153
+ return f"{base}/data/{entity_name}({encoded_key}){action_path}"
154
+ else:
155
+ # For simple string keys, wrap in quotes
156
+ return f"{base}/data/{entity_name}('{encoded_key}'){action_path}"
157
+ elif entity_name:
158
+ # Bound action on entity set
159
+ return f"{base}/data/{entity_name}{action_path}"
160
+ else:
161
+ # Unbound action
162
+ return f"{base}/data{action_path}"
@@ -0,0 +1,60 @@
1
+ """HTTP session management for D365 F&O client."""
2
+
3
+ from typing import Optional
4
+
5
+ import aiohttp
6
+
7
+ from .auth import AuthenticationManager
8
+ from .models import FOClientConfig
9
+
10
+
11
+ class SessionManager:
12
+ """Manages HTTP sessions with authentication"""
13
+
14
+ def __init__(self, config: FOClientConfig, auth_manager: AuthenticationManager):
15
+ """Initialize session manager
16
+
17
+ Args:
18
+ config: F&O client configuration
19
+ auth_manager: Authentication manager instance
20
+ """
21
+ self.config = config
22
+ self.auth_manager = auth_manager
23
+ self._session: Optional[aiohttp.ClientSession] = None
24
+
25
+ async def get_session(self) -> aiohttp.ClientSession:
26
+ """Get HTTP session with auth headers
27
+
28
+ Returns:
29
+ Configured aiohttp ClientSession
30
+ """
31
+ if self._session is None or self._session.closed:
32
+ connector = aiohttp.TCPConnector(ssl=self.config.verify_ssl)
33
+ timeout = aiohttp.ClientTimeout(total=self.config.timeout)
34
+ self._session = aiohttp.ClientSession(connector=connector, timeout=timeout)
35
+
36
+ # Update headers with fresh token
37
+ token = await self.auth_manager.get_token()
38
+ self._session.headers.update(
39
+ {
40
+ "Authorization": f"Bearer {token}",
41
+ "Accept": "application/json",
42
+ "Content-Type": "application/json",
43
+ }
44
+ )
45
+
46
+ return self._session
47
+
48
+ async def close(self):
49
+ """Close the HTTP session"""
50
+ if self._session:
51
+ await self._session.close()
52
+ self._session = None
53
+
54
+ async def __aenter__(self):
55
+ """Async context manager entry"""
56
+ return self
57
+
58
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
59
+ """Async context manager exit"""
60
+ await self.close()
d365fo_client/utils.py ADDED
@@ -0,0 +1,196 @@
1
+ """Utility functions for D365 F&O client."""
2
+
3
+ import os
4
+ import platform
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Union
8
+ from urllib.parse import urlparse
9
+
10
+
11
+ def get_user_cache_dir(app_name: str = "d365fo-client") -> Path:
12
+ r"""Get the appropriate user cache directory for the current platform.
13
+
14
+ This function follows platform conventions for cache directories:
15
+ - Windows: %LOCALAPPDATA%\<app_name> (e.g., C:\Users\username\AppData\Local\d365fo-client)
16
+ - macOS: ~/Library/Caches/<app_name> (e.g., /Users/username/Library/Caches/d365fo-client)
17
+ - Linux: ~/.cache/<app_name> (e.g., /home/username/.cache/d365fo-client)
18
+
19
+ Args:
20
+ app_name: Name of the application (used as directory name)
21
+
22
+ Returns:
23
+ Path object pointing to the cache directory
24
+
25
+ Examples:
26
+ >>> cache_dir = get_user_cache_dir()
27
+ >>> print(cache_dir) # doctest: +SKIP
28
+ WindowsPath('C:/Users/username/AppData/Local/d365fo-client')
29
+
30
+ >>> cache_dir = get_user_cache_dir("my-app")
31
+ >>> "my-app" in str(cache_dir)
32
+ True
33
+ """
34
+ system = platform.system()
35
+
36
+ if system == "Windows":
37
+ # Use LOCALAPPDATA for cache data on Windows
38
+ # Falls back to APPDATA if LOCALAPPDATA is not available
39
+ cache_root = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
40
+ if cache_root:
41
+ return Path(cache_root) / app_name
42
+ else:
43
+ # Fallback: use user home directory
44
+ return Path.home() / "AppData" / "Local" / app_name
45
+
46
+ elif system == "Darwin": # macOS
47
+ # Use ~/Library/Caches on macOS
48
+ return Path.home() / "Library" / "Caches" / app_name
49
+
50
+ else: # Linux and other Unix-like systems
51
+ # Use XDG_CACHE_HOME if set, otherwise ~/.cache
52
+ cache_root = os.environ.get("XDG_CACHE_HOME")
53
+ if cache_root:
54
+ return Path(cache_root) / app_name
55
+ else:
56
+ return Path.home() / ".cache" / app_name
57
+
58
+
59
+ def ensure_directory_exists(path: Union[str, Path]) -> Path:
60
+ """Ensure a directory exists, creating it if necessary.
61
+
62
+ Args:
63
+ path: Directory path to create
64
+
65
+ Returns:
66
+ Path object pointing to the directory
67
+
68
+ Raises:
69
+ OSError: If directory creation fails
70
+ """
71
+ path_obj = Path(path)
72
+ path_obj.mkdir(parents=True, exist_ok=True)
73
+ return path_obj
74
+
75
+
76
+ def get_default_cache_directory() -> str:
77
+ r"""Get the default cache directory for d365fo-client.
78
+
79
+ This is a convenience function that returns the appropriate cache directory
80
+ as a string, ready to be used as the default value for metadata_cache_dir.
81
+
82
+ Returns:
83
+ String path to the default cache directory
84
+
85
+ Examples:
86
+ >>> cache_dir = get_default_cache_directory()
87
+ >>> "d365fo-client" in cache_dir
88
+ True
89
+ """
90
+ return str(get_user_cache_dir())
91
+
92
+
93
+ def extract_domain_from_url(url: str) -> str:
94
+ """Extract and sanitize domain name from URL for use as directory name.
95
+
96
+ Args:
97
+ url: The base URL (e.g., "https://mycompany.sandbox.operations.dynamics.com")
98
+
99
+ Returns:
100
+ Sanitized domain name suitable for directory name
101
+
102
+ Examples:
103
+ >>> extract_domain_from_url("https://mycompany.sandbox.operations.dynamics.com")
104
+ 'mycompany.sandbox.operations.dynamics.com'
105
+
106
+ >>> extract_domain_from_url("https://test-env.dynamics.com/")
107
+ 'test-env.dynamics.com'
108
+
109
+ >>> extract_domain_from_url("https://localhost:8080")
110
+ 'localhost_8080'
111
+ """
112
+ if not url or not url.strip():
113
+ return "unknown-domain"
114
+
115
+ try:
116
+ parsed = urlparse(url)
117
+ domain = parsed.netloc.lower()
118
+
119
+ # If no netloc (malformed URL), try to extract something useful
120
+ if not domain:
121
+ # Try to extract domain-like pattern from the URL
122
+ domain_match = re.search(r"([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", url)
123
+ if domain_match:
124
+ domain = domain_match.group(1).lower()
125
+ else:
126
+ # Fallback: create a safe name from the original URL
127
+ safe_name = re.sub(r"[^\w\.-]", "_", url.lower())
128
+ return safe_name[:50] or "unknown-domain"
129
+
130
+ # Remove default ports for common schemes
131
+ if (parsed.scheme == "https" and domain.endswith(":443")) or (
132
+ parsed.scheme == "http" and domain.endswith(":80")
133
+ ):
134
+ domain = domain.rsplit(":", 1)[0]
135
+
136
+ # Replace invalid filesystem characters with underscore
137
+ # Windows reserved characters: < > : " | ? * \ /
138
+ # Also replace other potentially problematic characters
139
+ domain = re.sub(r'[<>:"|?*\\/]', "_", domain)
140
+
141
+ # Replace remaining special characters that might cause issues
142
+ domain = re.sub(r"[^\w\.-]", "_", domain)
143
+
144
+ return domain or "unknown-domain"
145
+
146
+ except Exception:
147
+ # Fallback: create a safe name from the original URL
148
+ safe_name = re.sub(r"[^\w\.-]", "_", url.lower())
149
+ return safe_name[:50] or "unknown-domain" # Limit length
150
+
151
+
152
+ def get_environment_cache_dir(base_url: str, app_name: str = "d365fo-client") -> Path:
153
+ """Get environment-specific cache directory based on F&O base URL.
154
+
155
+ This creates a separate cache directory for each F&O environment, allowing
156
+ users to work with multiple environments without cache conflicts.
157
+
158
+ Args:
159
+ base_url: F&O environment base URL
160
+ app_name: Application name (default: "d365fo-client")
161
+
162
+ Returns:
163
+ Path object pointing to the environment-specific cache directory
164
+
165
+ Examples:
166
+ >>> cache_dir = get_environment_cache_dir("https://usnconeboxax1aos.cloud.onebox.dynamics.com")
167
+ >>> "usnconeboxax1aos.cloud.onebox.dynamics.com" in str(cache_dir)
168
+ True
169
+
170
+ >>> cache_dir = get_environment_cache_dir("https://test.dynamics.com", "my-app")
171
+ >>> "test.dynamics.com" in str(cache_dir) and "my-app" in str(cache_dir)
172
+ True
173
+ """
174
+ domain = extract_domain_from_url(base_url)
175
+ base_cache_dir = get_user_cache_dir(app_name)
176
+ return base_cache_dir / domain
177
+
178
+
179
+ def get_environment_cache_directory(base_url: str) -> str:
180
+ """Get environment-specific cache directory as string.
181
+
182
+ Convenience function that returns the environment-specific cache directory
183
+ as a string, ready to be used for metadata_cache_dir.
184
+
185
+ Args:
186
+ base_url: F&O environment base URL
187
+
188
+ Returns:
189
+ String path to the environment-specific cache directory
190
+
191
+ Examples:
192
+ >>> cache_dir = get_environment_cache_directory("https://usnconeboxax1aos.cloud.onebox.dynamics.com")
193
+ >>> isinstance(cache_dir, str) and "usnconeboxax1aos.cloud.onebox.dynamics.com" in cache_dir
194
+ True
195
+ """
196
+ return str(get_environment_cache_dir(base_url))