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
@@ -0,0 +1,305 @@
|
|
1
|
+
"""Microsoft Dynamics 365 Finance & Operations client package.
|
2
|
+
|
3
|
+
A comprehensive Python client for connecting to D365 F&O and performing:
|
4
|
+
- Metadata download, storage, and search
|
5
|
+
- OData action method calls
|
6
|
+
- CRUD operations on data entities
|
7
|
+
- OData query parameters support
|
8
|
+
- Label text retrieval and caching
|
9
|
+
- Multilingual label support
|
10
|
+
- Entity metadata with resolved labels
|
11
|
+
|
12
|
+
Basic Usage:
|
13
|
+
from d365fo_client import FOClient, FOClientConfig
|
14
|
+
|
15
|
+
config = FOClientConfig(
|
16
|
+
base_url="https://your-fo-environment.dynamics.com",
|
17
|
+
use_default_credentials=True
|
18
|
+
)
|
19
|
+
|
20
|
+
async with FOClient(config) as client:
|
21
|
+
# Download metadata
|
22
|
+
await client.download_metadata()
|
23
|
+
|
24
|
+
# Search entities
|
25
|
+
entities = client.search_entities("customer")
|
26
|
+
|
27
|
+
# Get entities
|
28
|
+
customers = await client.get_entities("Customers", top=10)
|
29
|
+
|
30
|
+
# Get labels
|
31
|
+
label_text = await client.get_label_text("@SYS13342")
|
32
|
+
|
33
|
+
Quick Start:
|
34
|
+
from d365fo_client import create_client
|
35
|
+
|
36
|
+
client = create_client("https://your-fo-environment.dynamics.com")
|
37
|
+
"""
|
38
|
+
|
39
|
+
import sys
|
40
|
+
from pathlib import Path
|
41
|
+
|
42
|
+
|
43
|
+
# Dynamic version, author, and email retrieval
|
44
|
+
def _get_package_metadata():
|
45
|
+
"""Get package metadata from installed package or pyproject.toml."""
|
46
|
+
package_name = "d365fo-client"
|
47
|
+
|
48
|
+
# Try to get from installed package metadata first (works after pip install)
|
49
|
+
try:
|
50
|
+
from importlib.metadata import metadata
|
51
|
+
|
52
|
+
pkg_metadata = metadata(package_name)
|
53
|
+
|
54
|
+
version = pkg_metadata["Version"]
|
55
|
+
authors = pkg_metadata.get("Author-email", "").split(", ")
|
56
|
+
|
57
|
+
# Parse author and email from "Name <email>" format
|
58
|
+
if authors and authors[0]:
|
59
|
+
author_email = authors[0]
|
60
|
+
if "<" in author_email and ">" in author_email:
|
61
|
+
author = author_email.split("<")[0].strip()
|
62
|
+
email = author_email.split("<")[1].split(">")[0].strip()
|
63
|
+
else:
|
64
|
+
# Fallback if format is different
|
65
|
+
author = pkg_metadata.get("Author", "Muhammad Afzaal")
|
66
|
+
email = author_email if "@" in author_email else "mo@thedataguy.pro"
|
67
|
+
else:
|
68
|
+
author = pkg_metadata.get("Author", "Muhammad Afzaal")
|
69
|
+
email = "mo@thedataguy.pro"
|
70
|
+
|
71
|
+
return version, author, email
|
72
|
+
|
73
|
+
except ImportError:
|
74
|
+
# importlib.metadata not available (Python < 3.8)
|
75
|
+
pass
|
76
|
+
except Exception:
|
77
|
+
# Package not installed or other error
|
78
|
+
pass
|
79
|
+
|
80
|
+
# Fallback: try to read from pyproject.toml (development mode)
|
81
|
+
try:
|
82
|
+
# Try to find pyproject.toml in package directory or parent directories
|
83
|
+
current_file = Path(__file__)
|
84
|
+
for parent in [
|
85
|
+
current_file.parent,
|
86
|
+
current_file.parent.parent,
|
87
|
+
current_file.parent.parent.parent,
|
88
|
+
]:
|
89
|
+
pyproject_path = parent / "pyproject.toml"
|
90
|
+
if pyproject_path.exists():
|
91
|
+
# Try to use tomllib for Python 3.11+
|
92
|
+
tomllib = None
|
93
|
+
if sys.version_info >= (3, 11):
|
94
|
+
try:
|
95
|
+
import tomllib
|
96
|
+
except ImportError:
|
97
|
+
tomllib = None
|
98
|
+
|
99
|
+
if tomllib:
|
100
|
+
with open(pyproject_path, "rb") as f:
|
101
|
+
data = tomllib.load(f)
|
102
|
+
|
103
|
+
# Use tomllib parsed data
|
104
|
+
project = data.get("project", {})
|
105
|
+
version = project.get("version", "0.1.0")
|
106
|
+
|
107
|
+
authors = project.get("authors", [])
|
108
|
+
if authors and len(authors) > 0:
|
109
|
+
author = authors[0].get("name", "Muhammad Afzaal")
|
110
|
+
email = authors[0].get("email", "mo@thedataguy.pro")
|
111
|
+
else:
|
112
|
+
author = "Muhammad Afzaal"
|
113
|
+
email = "mo@thedataguy.pro"
|
114
|
+
|
115
|
+
return version, author, email
|
116
|
+
else:
|
117
|
+
# Fallback for Python < 3.11: simple parsing
|
118
|
+
import re
|
119
|
+
|
120
|
+
with open(pyproject_path, "r", encoding="utf-8") as f:
|
121
|
+
content = f.read()
|
122
|
+
|
123
|
+
version_match = re.search(
|
124
|
+
r'version\s*=\s*["\']([^"\']+)["\']', content
|
125
|
+
)
|
126
|
+
author_match = re.search(
|
127
|
+
r'name\s*=\s*["\']([^"\']+)["\'].*?email\s*=\s*["\']([^"\']+)["\']',
|
128
|
+
content,
|
129
|
+
re.DOTALL,
|
130
|
+
)
|
131
|
+
|
132
|
+
if version_match:
|
133
|
+
version = version_match.group(1)
|
134
|
+
else:
|
135
|
+
version = "0.1.0"
|
136
|
+
|
137
|
+
if author_match:
|
138
|
+
author = author_match.group(1)
|
139
|
+
email = author_match.group(2)
|
140
|
+
else:
|
141
|
+
author = "Muhammad Afzaal"
|
142
|
+
email = "mo@thedataguy.pro"
|
143
|
+
|
144
|
+
return version, author, email
|
145
|
+
|
146
|
+
except Exception:
|
147
|
+
# If all else fails, use fallback values
|
148
|
+
pass
|
149
|
+
|
150
|
+
# Ultimate fallback
|
151
|
+
return "0.1.0", "Muhammad Afzaal", "mo@thedataguy.pro"
|
152
|
+
|
153
|
+
|
154
|
+
__version__, __author__, __email__ = _get_package_metadata()
|
155
|
+
|
156
|
+
from .cli import CLIManager
|
157
|
+
|
158
|
+
# Import main classes and functions for public API
|
159
|
+
from .client import FOClient, create_client
|
160
|
+
from .config import ConfigManager
|
161
|
+
from .exceptions import (
|
162
|
+
ActionError,
|
163
|
+
AuthenticationError,
|
164
|
+
ConfigurationError,
|
165
|
+
EntityError,
|
166
|
+
FOClientError,
|
167
|
+
LabelError,
|
168
|
+
MetadataError,
|
169
|
+
NetworkError,
|
170
|
+
)
|
171
|
+
from .labels import resolve_labels_generic, resolve_labels_generic_with_cache
|
172
|
+
from .main import main
|
173
|
+
|
174
|
+
# MCP Server
|
175
|
+
from .mcp import D365FOClientManager, D365FOMCPServer
|
176
|
+
|
177
|
+
# Legacy Metadata Cache (deprecated - use metadata_v2)
|
178
|
+
# REMOVED: Legacy classes have been replaced with V2 implementations
|
179
|
+
# from .metadata_cache import MetadataCache, MetadataSearchEngine
|
180
|
+
|
181
|
+
# V2 Metadata Cache (recommended - now the only implementation)
|
182
|
+
from .metadata_v2 import MetadataCacheV2, VersionAwareSearchEngine
|
183
|
+
|
184
|
+
# Provide backward compatibility with immediate import errors
|
185
|
+
import warnings
|
186
|
+
|
187
|
+
class _DeprecatedMetadataCache:
|
188
|
+
"""Deprecated placeholder for MetadataCache - raises error on any access"""
|
189
|
+
def __init__(self, *args, **kwargs):
|
190
|
+
warnings.warn(
|
191
|
+
"MetadataCache is deprecated and has been removed. "
|
192
|
+
"Use MetadataCacheV2 from d365fo_client.metadata_v2 instead.",
|
193
|
+
DeprecationWarning,
|
194
|
+
stacklevel=2
|
195
|
+
)
|
196
|
+
raise ImportError(
|
197
|
+
"MetadataCache has been removed. Use MetadataCacheV2 from d365fo_client.metadata_v2 instead."
|
198
|
+
)
|
199
|
+
|
200
|
+
def __getattr__(self, name):
|
201
|
+
raise ImportError(
|
202
|
+
"MetadataCache has been removed. Use MetadataCacheV2 from d365fo_client.metadata_v2 instead."
|
203
|
+
)
|
204
|
+
|
205
|
+
class _DeprecatedMetadataSearchEngine:
|
206
|
+
"""Deprecated placeholder for MetadataSearchEngine - raises error on any access"""
|
207
|
+
def __init__(self, *args, **kwargs):
|
208
|
+
warnings.warn(
|
209
|
+
"MetadataSearchEngine is deprecated and has been removed. "
|
210
|
+
"Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead.",
|
211
|
+
DeprecationWarning,
|
212
|
+
stacklevel=2
|
213
|
+
)
|
214
|
+
raise ImportError(
|
215
|
+
"MetadataSearchEngine has been removed. Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead."
|
216
|
+
)
|
217
|
+
|
218
|
+
def __getattr__(self, name):
|
219
|
+
raise ImportError(
|
220
|
+
"MetadataSearchEngine has been removed. Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead."
|
221
|
+
)
|
222
|
+
|
223
|
+
# Create deprecated placeholder classes
|
224
|
+
MetadataCache = _DeprecatedMetadataCache
|
225
|
+
MetadataSearchEngine = _DeprecatedMetadataSearchEngine
|
226
|
+
from .models import (
|
227
|
+
ActionInfo,
|
228
|
+
DataEntityInfo,
|
229
|
+
EnumerationInfo,
|
230
|
+
EnumerationMemberInfo,
|
231
|
+
FOClientConfig,
|
232
|
+
LabelInfo,
|
233
|
+
PublicEntityInfo,
|
234
|
+
PublicEntityPropertyInfo,
|
235
|
+
QueryOptions,
|
236
|
+
)
|
237
|
+
from .output import OutputFormatter
|
238
|
+
from .profile_manager import ProfileManager
|
239
|
+
from .profiles import Profile
|
240
|
+
from .utils import (
|
241
|
+
ensure_directory_exists,
|
242
|
+
extract_domain_from_url,
|
243
|
+
get_default_cache_directory,
|
244
|
+
get_environment_cache_dir,
|
245
|
+
get_environment_cache_directory,
|
246
|
+
get_user_cache_dir,
|
247
|
+
)
|
248
|
+
|
249
|
+
# Legacy aliases for backward compatibility
|
250
|
+
CLIProfile = Profile
|
251
|
+
EnvironmentProfile = Profile
|
252
|
+
|
253
|
+
# Public API
|
254
|
+
__all__ = [
|
255
|
+
# Main client
|
256
|
+
"FOClient",
|
257
|
+
"create_client",
|
258
|
+
# Legacy caching (deprecated placeholders - raise errors when used)
|
259
|
+
"MetadataCache",
|
260
|
+
"MetadataSearchEngine",
|
261
|
+
# V2 caching (now the primary implementation)
|
262
|
+
"MetadataCacheV2",
|
263
|
+
"VersionAwareSearchEngine",
|
264
|
+
"resolve_labels_generic",
|
265
|
+
# Configuration and models
|
266
|
+
"FOClientConfig",
|
267
|
+
"QueryOptions",
|
268
|
+
"LabelInfo",
|
269
|
+
"ActionInfo",
|
270
|
+
"DataEntityInfo",
|
271
|
+
"PublicEntityInfo",
|
272
|
+
"EnumerationInfo",
|
273
|
+
"PublicEntityPropertyInfo",
|
274
|
+
"EnumerationMemberInfo",
|
275
|
+
# Exceptions
|
276
|
+
"FOClientError",
|
277
|
+
"AuthenticationError",
|
278
|
+
"MetadataError",
|
279
|
+
"EntityError",
|
280
|
+
"ActionError",
|
281
|
+
"LabelError",
|
282
|
+
"ConfigurationError",
|
283
|
+
"NetworkError",
|
284
|
+
# Utilities
|
285
|
+
"get_user_cache_dir",
|
286
|
+
"get_default_cache_directory",
|
287
|
+
"ensure_directory_exists",
|
288
|
+
"extract_domain_from_url",
|
289
|
+
"get_environment_cache_dir",
|
290
|
+
"get_environment_cache_directory",
|
291
|
+
# CLI components
|
292
|
+
"OutputFormatter",
|
293
|
+
"ConfigManager",
|
294
|
+
"Profile",
|
295
|
+
"ProfileManager",
|
296
|
+
"CLIManager",
|
297
|
+
# Legacy aliases
|
298
|
+
"CLIProfile",
|
299
|
+
"EnvironmentProfile",
|
300
|
+
# MCP Server
|
301
|
+
"D365FOMCPServer",
|
302
|
+
"D365FOClientManager",
|
303
|
+
# Entry point
|
304
|
+
"main",
|
305
|
+
]
|
d365fo_client/auth.py
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
"""Authentication utilities for D365 F&O client."""
|
2
|
+
|
3
|
+
from datetime import datetime
|
4
|
+
from typing import Optional
|
5
|
+
|
6
|
+
from azure.identity import ClientSecretCredential, DefaultAzureCredential
|
7
|
+
|
8
|
+
from .models import FOClientConfig
|
9
|
+
|
10
|
+
|
11
|
+
class AuthenticationManager:
|
12
|
+
"""Manages authentication for F&O client"""
|
13
|
+
|
14
|
+
def __init__(self, config: FOClientConfig):
|
15
|
+
"""Initialize authentication manager
|
16
|
+
|
17
|
+
Args:
|
18
|
+
config: F&O client configuration
|
19
|
+
"""
|
20
|
+
self.config = config
|
21
|
+
self._token = None
|
22
|
+
self._token_expires = None
|
23
|
+
self.credential = self._setup_credentials()
|
24
|
+
|
25
|
+
def _setup_credentials(self):
|
26
|
+
"""Setup authentication credentials"""
|
27
|
+
if self.config.use_default_credentials:
|
28
|
+
return DefaultAzureCredential()
|
29
|
+
elif (
|
30
|
+
self.config.client_id
|
31
|
+
and self.config.client_secret
|
32
|
+
and self.config.tenant_id
|
33
|
+
):
|
34
|
+
return ClientSecretCredential(
|
35
|
+
tenant_id=self.config.tenant_id,
|
36
|
+
client_id=self.config.client_id,
|
37
|
+
client_secret=self.config.client_secret,
|
38
|
+
)
|
39
|
+
else:
|
40
|
+
raise ValueError(
|
41
|
+
"Must provide either use_default_credentials=True or client credentials"
|
42
|
+
)
|
43
|
+
|
44
|
+
async def get_token(self) -> str:
|
45
|
+
"""Get authentication token
|
46
|
+
|
47
|
+
Returns:
|
48
|
+
Bearer token string
|
49
|
+
"""
|
50
|
+
# Skip authentication for localhost/mock server
|
51
|
+
if self._is_localhost():
|
52
|
+
return "mock-token-for-localhost"
|
53
|
+
|
54
|
+
if (
|
55
|
+
self._token
|
56
|
+
and self._token_expires
|
57
|
+
and datetime.now().timestamp() < self._token_expires
|
58
|
+
):
|
59
|
+
return self._token
|
60
|
+
|
61
|
+
# Try different scopes
|
62
|
+
scopes_to_try = [
|
63
|
+
f"{self.config.base_url}/.default",
|
64
|
+
f"{self.config.client_id}/.default" if self.config.client_id else None,
|
65
|
+
]
|
66
|
+
|
67
|
+
for scope in scopes_to_try:
|
68
|
+
if not scope:
|
69
|
+
continue
|
70
|
+
try:
|
71
|
+
token = self.credential.get_token(scope)
|
72
|
+
self._token = token.token
|
73
|
+
self._token_expires = token.expires_on
|
74
|
+
return self._token
|
75
|
+
except Exception as e:
|
76
|
+
print(f"Failed to get token with scope {scope}: {e}")
|
77
|
+
continue
|
78
|
+
|
79
|
+
raise Exception("Failed to get authentication token")
|
80
|
+
|
81
|
+
def _is_localhost(self) -> bool:
|
82
|
+
"""Check if the base URL is localhost (for mock testing)
|
83
|
+
|
84
|
+
Returns:
|
85
|
+
True if base URL is localhost/127.0.0.1
|
86
|
+
"""
|
87
|
+
base_url = self.config.base_url.lower()
|
88
|
+
return any(host in base_url for host in ["localhost", "127.0.0.1", "::1"])
|
89
|
+
|
90
|
+
def invalidate_token(self):
|
91
|
+
"""Invalidate cached token to force refresh"""
|
92
|
+
self._token = None
|
93
|
+
self._token_expires = None
|