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.
- d365fo_client/__init__.py +305 -0
- d365fo_client/auth.py +93 -0
- d365fo_client/cli.py +700 -0
- d365fo_client/client.py +1454 -0
- d365fo_client/config.py +304 -0
- d365fo_client/crud.py +200 -0
- d365fo_client/exceptions.py +49 -0
- d365fo_client/labels.py +528 -0
- d365fo_client/main.py +502 -0
- d365fo_client/mcp/__init__.py +16 -0
- d365fo_client/mcp/client_manager.py +276 -0
- d365fo_client/mcp/main.py +98 -0
- d365fo_client/mcp/models.py +371 -0
- d365fo_client/mcp/prompts/__init__.py +43 -0
- d365fo_client/mcp/prompts/action_execution.py +480 -0
- d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
- d365fo_client/mcp/resources/__init__.py +15 -0
- d365fo_client/mcp/resources/database_handler.py +555 -0
- d365fo_client/mcp/resources/entity_handler.py +176 -0
- d365fo_client/mcp/resources/environment_handler.py +132 -0
- d365fo_client/mcp/resources/metadata_handler.py +283 -0
- d365fo_client/mcp/resources/query_handler.py +135 -0
- d365fo_client/mcp/server.py +432 -0
- d365fo_client/mcp/tools/__init__.py +17 -0
- d365fo_client/mcp/tools/connection_tools.py +175 -0
- d365fo_client/mcp/tools/crud_tools.py +579 -0
- d365fo_client/mcp/tools/database_tools.py +813 -0
- d365fo_client/mcp/tools/label_tools.py +189 -0
- d365fo_client/mcp/tools/metadata_tools.py +766 -0
- d365fo_client/mcp/tools/profile_tools.py +706 -0
- d365fo_client/metadata_api.py +793 -0
- d365fo_client/metadata_v2/__init__.py +59 -0
- d365fo_client/metadata_v2/cache_v2.py +1372 -0
- d365fo_client/metadata_v2/database_v2.py +585 -0
- d365fo_client/metadata_v2/global_version_manager.py +573 -0
- d365fo_client/metadata_v2/search_engine_v2.py +423 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
- d365fo_client/metadata_v2/version_detector.py +439 -0
- d365fo_client/models.py +862 -0
- d365fo_client/output.py +181 -0
- d365fo_client/profile_manager.py +342 -0
- d365fo_client/profiles.py +178 -0
- d365fo_client/query.py +162 -0
- d365fo_client/session.py +60 -0
- d365fo_client/utils.py +196 -0
- d365fo_client-0.1.0.dist-info/METADATA +1084 -0
- d365fo_client-0.1.0.dist-info/RECORD +51 -0
- d365fo_client-0.1.0.dist-info/WHEEL +5 -0
- d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
- d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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}"
|
d365fo_client/session.py
ADDED
@@ -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))
|