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,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