d365fo-client 0.2.1__tar.gz → 0.2.2__tar.gz
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-0.2.1/src/d365fo_client.egg-info → d365fo_client-0.2.2}/PKG-INFO +1 -1
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/pyproject.toml +1 -1
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/__init__.py +4 -48
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/client.py +44 -24
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/models.py +2 -2
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/connection_tools.py +7 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_api.py +1 -1
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/cache_v2.py +15 -10
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/database_v2.py +93 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/global_version_manager.py +60 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/models.py +19 -10
- {d365fo_client-0.2.1 → d365fo_client-0.2.2/src/d365fo_client.egg-info}/PKG-INFO +1 -1
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/LICENSE +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/README.md +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/setup.cfg +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/auth.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/cli.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/config.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/crud.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/exceptions.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/labels.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/main.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/__init__.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/client_manager.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/main.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/__init__.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/action_execution.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/sequence_analysis.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/__init__.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/database_handler.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/entity_handler.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/environment_handler.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/metadata_handler.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/query_handler.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/server.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/__init__.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/crud_tools.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/database_tools.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/label_tools.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/metadata_tools.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/profile_tools.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/__init__.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/label_utils.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/search_engine_v2.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/sync_manager_v2.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/version_detector.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/output.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/profile_manager.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/profiles.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/query.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/session.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/utils.py +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/SOURCES.txt +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/dependency_links.txt +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/entry_points.txt +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/requires.txt +0 -0
- {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/top_level.txt +0 -0
@@ -174,55 +174,13 @@ from .main import main
|
|
174
174
|
# MCP Server
|
175
175
|
from .mcp import D365FOClientManager, D365FOMCPServer
|
176
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
177
|
# V2 Metadata Cache (recommended - now the only implementation)
|
182
178
|
from .metadata_v2 import MetadataCacheV2, VersionAwareSearchEngine
|
183
179
|
|
184
180
|
# Provide backward compatibility with immediate import errors
|
185
|
-
|
186
|
-
|
187
|
-
|
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
|
181
|
+
|
182
|
+
|
183
|
+
|
226
184
|
from .models import (
|
227
185
|
ActionInfo,
|
228
186
|
DataEntityInfo,
|
@@ -255,9 +213,7 @@ __all__ = [
|
|
255
213
|
# Main client
|
256
214
|
"FOClient",
|
257
215
|
"create_client",
|
258
|
-
|
259
|
-
"MetadataCache",
|
260
|
-
"MetadataSearchEngine",
|
216
|
+
|
261
217
|
# V2 caching (now the primary implementation)
|
262
218
|
"MetadataCacheV2",
|
263
219
|
"VersionAwareSearchEngine",
|
@@ -113,23 +113,27 @@ class FOClient:
|
|
113
113
|
self.config.enable_metadata_cache = False
|
114
114
|
|
115
115
|
async def _trigger_background_sync_if_needed(self):
|
116
|
-
"""Trigger background sync if metadata is stale or missing"""
|
116
|
+
"""Trigger background sync if metadata is stale or missing (non-blocking)"""
|
117
117
|
if not self.config.enable_metadata_cache or not self._metadata_initialized:
|
118
118
|
return
|
119
119
|
|
120
|
+
# Don't trigger sync if already running
|
121
|
+
if self._is_background_sync_running():
|
122
|
+
return
|
123
|
+
|
120
124
|
try:
|
121
125
|
# Check if we need to sync using the new v2 API
|
126
|
+
# This should be a quick check, not actual sync work
|
122
127
|
sync_needed, global_version_id = (
|
123
128
|
await self.metadata_cache.check_version_and_sync(self.metadata_api_ops)
|
124
129
|
)
|
125
130
|
|
126
131
|
if sync_needed and global_version_id:
|
127
|
-
#
|
128
|
-
|
129
|
-
self.
|
130
|
-
|
131
|
-
|
132
|
-
self.logger.debug("Background metadata sync triggered")
|
132
|
+
# Start sync in background without awaiting it
|
133
|
+
self._background_sync_task = asyncio.create_task(
|
134
|
+
self._background_sync_worker(global_version_id)
|
135
|
+
)
|
136
|
+
self.logger.debug("Background metadata sync triggered")
|
133
137
|
except Exception as e:
|
134
138
|
self.logger.warning(f"Failed to check sync status: {e}")
|
135
139
|
|
@@ -222,7 +226,8 @@ class FOClient:
|
|
222
226
|
|
223
227
|
# If cache returns empty result, trigger sync and try fallback
|
224
228
|
if not result or (isinstance(result, list) and len(result) == 0):
|
225
|
-
|
229
|
+
# Trigger background sync without awaiting (fire-and-forget)
|
230
|
+
asyncio.create_task(self._trigger_background_sync_if_needed())
|
226
231
|
return (
|
227
232
|
await fallback_method(*args, **kwargs)
|
228
233
|
if asyncio.iscoroutinefunction(fallback_method)
|
@@ -233,8 +238,8 @@ class FOClient:
|
|
233
238
|
|
234
239
|
except Exception as e:
|
235
240
|
self.logger.warning(f"Cache lookup failed, using fallback: {e}")
|
236
|
-
# Trigger sync if cache failed
|
237
|
-
|
241
|
+
# Trigger sync if cache failed (fire-and-forget)
|
242
|
+
asyncio.create_task(self._trigger_background_sync_if_needed())
|
238
243
|
return (
|
239
244
|
await fallback_method(*args, **kwargs)
|
240
245
|
if asyncio.iscoroutinefunction(fallback_method)
|
@@ -1037,29 +1042,44 @@ class FOClient:
|
|
1037
1042
|
try:
|
1038
1043
|
import asyncio
|
1039
1044
|
|
1040
|
-
#
|
1045
|
+
# Always check if we're in an async context first
|
1041
1046
|
try:
|
1042
|
-
loop = asyncio.
|
1043
|
-
|
1044
|
-
loop = asyncio.new_event_loop()
|
1045
|
-
asyncio.set_event_loop(loop)
|
1046
|
-
|
1047
|
-
if loop.is_running():
|
1048
|
-
# If we're in an async context, we can't run async code synchronously
|
1047
|
+
loop = asyncio.get_running_loop()
|
1048
|
+
# We're in an async context, can't run async code synchronously
|
1049
1049
|
return {
|
1050
1050
|
"enabled": True,
|
1051
1051
|
"cache_type": "metadata_v2",
|
1052
1052
|
"message": "Label caching enabled with v2 cache (statistics available via async method)",
|
1053
1053
|
}
|
1054
|
-
|
1055
|
-
#
|
1056
|
-
|
1057
|
-
|
1058
|
-
|
1054
|
+
except RuntimeError:
|
1055
|
+
# No running loop, we can safely create one
|
1056
|
+
pass
|
1057
|
+
|
1058
|
+
# Create a new event loop for synchronous execution
|
1059
|
+
try:
|
1060
|
+
loop = asyncio.new_event_loop()
|
1061
|
+
asyncio.set_event_loop(loop)
|
1062
|
+
try:
|
1063
|
+
stats = loop.run_until_complete(
|
1064
|
+
self.metadata_cache.get_label_cache_statistics()
|
1065
|
+
)
|
1066
|
+
return {
|
1067
|
+
"enabled": True,
|
1068
|
+
"cache_type": "metadata_v2",
|
1069
|
+
"statistics": stats,
|
1070
|
+
}
|
1071
|
+
finally:
|
1072
|
+
loop.close()
|
1073
|
+
# Remove the loop to clean up
|
1074
|
+
try:
|
1075
|
+
asyncio.set_event_loop(None)
|
1076
|
+
except:
|
1077
|
+
pass
|
1078
|
+
except Exception as e:
|
1059
1079
|
return {
|
1060
1080
|
"enabled": True,
|
1061
1081
|
"cache_type": "metadata_v2",
|
1062
|
-
"
|
1082
|
+
"error": f"Error getting statistics: {e}",
|
1063
1083
|
}
|
1064
1084
|
except Exception as e:
|
1065
1085
|
return {
|
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
from dataclasses import dataclass
|
4
4
|
from datetime import datetime
|
5
|
-
from enum import
|
5
|
+
from enum import StrEnum
|
6
6
|
from typing import Any, Dict, List, Optional, Union
|
7
7
|
|
8
8
|
# Resource Models
|
@@ -37,7 +37,7 @@ class EntityResourceContent:
|
|
37
37
|
last_updated: Optional[str] = None
|
38
38
|
|
39
39
|
|
40
|
-
class MetadataType(
|
40
|
+
class MetadataType(StrEnum):
|
41
41
|
ENTITIES = "entities"
|
42
42
|
ACTIONS = "actions"
|
43
43
|
ENUMERATIONS = "enumerations"
|
@@ -8,6 +8,7 @@ from typing import List
|
|
8
8
|
from mcp import Tool
|
9
9
|
from mcp.types import TextContent
|
10
10
|
|
11
|
+
from ... import __version__
|
11
12
|
from ..client_manager import D365FOClientManager
|
12
13
|
|
13
14
|
logger = logging.getLogger(__name__)
|
@@ -95,6 +96,7 @@ class ConnectionTools:
|
|
95
96
|
response = {
|
96
97
|
"success": success,
|
97
98
|
"profile": profile,
|
99
|
+
"clientVersion": __version__,
|
98
100
|
"endpoints": {
|
99
101
|
"data": success,
|
100
102
|
"metadata": success, # Simplification for now
|
@@ -113,6 +115,7 @@ class ConnectionTools:
|
|
113
115
|
error_response = {
|
114
116
|
"success": False,
|
115
117
|
"profile": arguments.get("profile", "default"),
|
118
|
+
"clientVersion": __version__,
|
116
119
|
"endpoints": {"data": False, "metadata": False},
|
117
120
|
"responseTime": 0.0,
|
118
121
|
"error": str(e),
|
@@ -124,6 +127,7 @@ class ConnectionTools:
|
|
124
127
|
error_response = {
|
125
128
|
"success": False,
|
126
129
|
"profile": arguments.get("profile", "default"),
|
130
|
+
"clientVersion": __version__,
|
127
131
|
"endpoints": {"data": False, "metadata": False},
|
128
132
|
"responseTime": 0.0,
|
129
133
|
"error": str(e),
|
@@ -146,6 +150,7 @@ class ConnectionTools:
|
|
146
150
|
# Format response according to specification with enhanced metadata info
|
147
151
|
response = {
|
148
152
|
"baseUrl": env_info["base_url"],
|
153
|
+
"clientVersion": __version__,
|
149
154
|
"versions": env_info["versions"],
|
150
155
|
"connectivity": env_info["connectivity"],
|
151
156
|
"metadataInfo": env_info["metadata_info"],
|
@@ -160,6 +165,7 @@ class ConnectionTools:
|
|
160
165
|
)
|
161
166
|
error_response = {
|
162
167
|
"error": str(e),
|
168
|
+
"clientVersion": __version__,
|
163
169
|
"tool": "d365fo_get_environment_info",
|
164
170
|
"arguments": arguments,
|
165
171
|
"suggestion": "Please create a profile or set a default profile using the profile management tools.",
|
@@ -169,6 +175,7 @@ class ConnectionTools:
|
|
169
175
|
logger.error(f"Get environment info failed: {e}")
|
170
176
|
error_response = {
|
171
177
|
"error": str(e),
|
178
|
+
"clientVersion": __version__,
|
172
179
|
"tool": "d365fo_get_environment_info",
|
173
180
|
"arguments": arguments,
|
174
181
|
}
|
@@ -940,7 +940,7 @@ class MetadataAPIOperations:
|
|
940
940
|
continue
|
941
941
|
|
942
942
|
# Filter by binding kind
|
943
|
-
if binding_kind and action.binding_kind
|
943
|
+
if binding_kind and action.binding_kind != binding_kind:
|
944
944
|
continue
|
945
945
|
|
946
946
|
# Create ActionInfo with entity context
|
@@ -237,7 +237,7 @@ class MetadataCacheV2:
|
|
237
237
|
entity.public_collection_name,
|
238
238
|
entity.label_id,
|
239
239
|
processed_label_text, # Use processed label text
|
240
|
-
entity.entity_category
|
240
|
+
entity.entity_category if entity.entity_category else None,
|
241
241
|
entity.data_service_enabled,
|
242
242
|
entity.data_management_enabled,
|
243
243
|
entity.is_read_only,
|
@@ -481,7 +481,7 @@ class MetadataCacheV2:
|
|
481
481
|
nav_prop.name,
|
482
482
|
nav_prop.related_entity,
|
483
483
|
nav_prop.related_relation_name,
|
484
|
-
nav_prop.cardinality
|
484
|
+
nav_prop.cardinality, # StrEnum automatically converts to string
|
485
485
|
),
|
486
486
|
)
|
487
487
|
|
@@ -519,7 +519,7 @@ class MetadataCacheV2:
|
|
519
519
|
entity_id,
|
520
520
|
global_version_id,
|
521
521
|
action.name,
|
522
|
-
action.binding_kind
|
522
|
+
action.binding_kind, # StrEnum automatically converts to string
|
523
523
|
entity_schema.name,
|
524
524
|
entity_schema.entity_set_name,
|
525
525
|
action.return_type.type_name if action.return_type else None,
|
@@ -1495,19 +1495,24 @@ class MetadataCacheV2:
|
|
1495
1495
|
"""Get cache statistics
|
1496
1496
|
|
1497
1497
|
Returns:
|
1498
|
-
Dictionary with cache statistics
|
1498
|
+
Dictionary with cache statistics scoped to the current environment
|
1499
1499
|
"""
|
1500
|
+
await self.initialize()
|
1501
|
+
|
1502
|
+
if self._environment_id is None:
|
1503
|
+
raise ValueError("Environment not initialized")
|
1504
|
+
|
1500
1505
|
stats = {}
|
1501
1506
|
|
1502
|
-
#
|
1503
|
-
db_stats = await self.database.
|
1507
|
+
# Environment-scoped database statistics
|
1508
|
+
db_stats = await self.database.get_environment_database_statistics(self._environment_id)
|
1504
1509
|
stats.update(db_stats)
|
1505
1510
|
|
1506
|
-
#
|
1507
|
-
version_stats = await self.version_manager.
|
1511
|
+
# Environment-scoped version statistics
|
1512
|
+
version_stats = await self.version_manager.get_environment_version_statistics(self._environment_id)
|
1508
1513
|
stats["version_manager"] = version_stats
|
1509
1514
|
|
1510
|
-
# Current version info
|
1515
|
+
# Current version info (already environment-scoped)
|
1511
1516
|
current_version = await self._get_current_global_version_id()
|
1512
1517
|
if current_version:
|
1513
1518
|
version_info = await self.version_manager.get_global_version_info(
|
@@ -1521,7 +1526,7 @@ class MetadataCacheV2:
|
|
1521
1526
|
"reference_count": version_info.reference_count,
|
1522
1527
|
}
|
1523
1528
|
|
1524
|
-
# Label cache statistics
|
1529
|
+
# Label cache statistics (already environment-scoped via current_version)
|
1525
1530
|
label_stats = await self.get_label_cache_statistics(current_version)
|
1526
1531
|
stats["label_cache"] = label_stats
|
1527
1532
|
|
@@ -542,6 +542,99 @@ class MetadataDatabaseV2:
|
|
542
542
|
|
543
543
|
return stats
|
544
544
|
|
545
|
+
async def get_statistics(self) -> Dict[str, Any]:
|
546
|
+
"""Alias for get_database_statistics for backward compatibility
|
547
|
+
|
548
|
+
Returns:
|
549
|
+
Dictionary with database statistics
|
550
|
+
"""
|
551
|
+
return await self.get_database_statistics()
|
552
|
+
|
553
|
+
async def get_environment_database_statistics(self, environment_id: int) -> Dict[str, Any]:
|
554
|
+
"""Get database statistics scoped to a specific environment
|
555
|
+
|
556
|
+
Args:
|
557
|
+
environment_id: Environment ID to get statistics for
|
558
|
+
|
559
|
+
Returns:
|
560
|
+
Dictionary with environment-scoped database statistics
|
561
|
+
"""
|
562
|
+
async with aiosqlite.connect(self.db_path) as db:
|
563
|
+
stats = {}
|
564
|
+
|
565
|
+
# Get active global versions for this environment
|
566
|
+
cursor = await db.execute(
|
567
|
+
"""SELECT DISTINCT global_version_id
|
568
|
+
FROM environment_versions
|
569
|
+
WHERE environment_id = ? AND is_active = 1""",
|
570
|
+
(environment_id,)
|
571
|
+
)
|
572
|
+
active_versions = [row[0] for row in await cursor.fetchall()]
|
573
|
+
|
574
|
+
if not active_versions:
|
575
|
+
# No active versions, return zero counts
|
576
|
+
return {
|
577
|
+
"data_entities_count": 0,
|
578
|
+
"public_entities_count": 0,
|
579
|
+
"entity_properties_count": 0,
|
580
|
+
"navigation_properties_count": 0,
|
581
|
+
"entity_actions_count": 0,
|
582
|
+
"enumerations_count": 0,
|
583
|
+
"labels_cache_count": 0,
|
584
|
+
"environment_statistics": {
|
585
|
+
"total_environments": 1,
|
586
|
+
"linked_versions": 0,
|
587
|
+
},
|
588
|
+
"database_size_bytes": None,
|
589
|
+
"database_size_mb": None,
|
590
|
+
}
|
591
|
+
|
592
|
+
# Create placeholders for SQL IN clause
|
593
|
+
version_placeholders = ",".join("?" for _ in active_versions)
|
594
|
+
|
595
|
+
# Environment-scoped metadata counts
|
596
|
+
tables = [
|
597
|
+
("data_entities", "entities"),
|
598
|
+
("public_entities", "public_entities"),
|
599
|
+
("entity_properties", "properties"),
|
600
|
+
("navigation_properties", "navigation_properties"),
|
601
|
+
("entity_actions", "actions"),
|
602
|
+
("enumerations", "enumerations"),
|
603
|
+
("labels_cache", "labels"),
|
604
|
+
]
|
605
|
+
|
606
|
+
for table, key in tables:
|
607
|
+
cursor = await db.execute(
|
608
|
+
f"SELECT COUNT(*) FROM {table} WHERE global_version_id IN ({version_placeholders})",
|
609
|
+
active_versions
|
610
|
+
)
|
611
|
+
stats[f"{table}_count"] = (await cursor.fetchone())[0]
|
612
|
+
|
613
|
+
# Environment-specific statistics
|
614
|
+
cursor = await db.execute(
|
615
|
+
"""SELECT
|
616
|
+
COUNT(DISTINCT ev.global_version_id) as linked_versions
|
617
|
+
FROM environment_versions ev
|
618
|
+
WHERE ev.environment_id = ? AND ev.is_active = 1""",
|
619
|
+
(environment_id,)
|
620
|
+
)
|
621
|
+
env_stats = await cursor.fetchone()
|
622
|
+
stats["environment_statistics"] = {
|
623
|
+
"total_environments": 1, # Current environment only
|
624
|
+
"linked_versions": env_stats[0] or 0,
|
625
|
+
}
|
626
|
+
|
627
|
+
# Database file size (shared across all environments)
|
628
|
+
try:
|
629
|
+
db_size = self.db_path.stat().st_size
|
630
|
+
stats["database_size_bytes"] = db_size
|
631
|
+
stats["database_size_mb"] = round(db_size / (1024 * 1024), 2)
|
632
|
+
except Exception:
|
633
|
+
stats["database_size_bytes"] = None
|
634
|
+
stats["database_size_mb"] = None
|
635
|
+
|
636
|
+
return stats
|
637
|
+
|
545
638
|
async def vacuum_database(self) -> bool:
|
546
639
|
"""Vacuum database to reclaim space
|
547
640
|
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/global_version_manager.py
RENAMED
@@ -571,3 +571,63 @@ class GlobalVersionManager:
|
|
571
571
|
}
|
572
572
|
|
573
573
|
return stats
|
574
|
+
|
575
|
+
async def get_environment_version_statistics(self, environment_id: int) -> Dict[str, Any]:
|
576
|
+
"""Get version statistics scoped to a specific environment
|
577
|
+
|
578
|
+
Args:
|
579
|
+
environment_id: Environment ID to get statistics for
|
580
|
+
|
581
|
+
Returns:
|
582
|
+
Dictionary with environment-scoped version statistics
|
583
|
+
"""
|
584
|
+
async with aiosqlite.connect(self.db_path) as db:
|
585
|
+
stats = {}
|
586
|
+
|
587
|
+
# Get versions for this specific environment
|
588
|
+
cursor = await db.execute(
|
589
|
+
"""SELECT COUNT(DISTINCT global_version_id)
|
590
|
+
FROM environment_versions
|
591
|
+
WHERE environment_id = ? AND is_active = 1""",
|
592
|
+
(environment_id,)
|
593
|
+
)
|
594
|
+
stats["total_versions"] = (await cursor.fetchone())[0]
|
595
|
+
|
596
|
+
# This environment only
|
597
|
+
stats["total_environments"] = 1
|
598
|
+
|
599
|
+
# Reference statistics for this environment's versions
|
600
|
+
cursor = await db.execute(
|
601
|
+
"""SELECT
|
602
|
+
SUM(gv.reference_count) as total_references,
|
603
|
+
AVG(gv.reference_count) as avg_references,
|
604
|
+
MAX(gv.reference_count) as max_references,
|
605
|
+
COUNT(*) as versions_with_refs
|
606
|
+
FROM global_versions gv
|
607
|
+
INNER JOIN environment_versions ev ON gv.id = ev.global_version_id
|
608
|
+
WHERE ev.environment_id = ? AND ev.is_active = 1 AND gv.reference_count > 0""",
|
609
|
+
(environment_id,)
|
610
|
+
)
|
611
|
+
ref_stats = await cursor.fetchone()
|
612
|
+
stats["reference_statistics"] = {
|
613
|
+
"total_references": ref_stats[0] or 0,
|
614
|
+
"avg_references": round(ref_stats[1] or 0, 2),
|
615
|
+
"max_references": ref_stats[2] or 0,
|
616
|
+
"versions_with_references": ref_stats[3] or 0,
|
617
|
+
}
|
618
|
+
|
619
|
+
# Version age statistics for this environment
|
620
|
+
cursor = await db.execute(
|
621
|
+
"""SELECT
|
622
|
+
COUNT(*) as recent_versions
|
623
|
+
FROM global_versions gv
|
624
|
+
INNER JOIN environment_versions ev ON gv.id = ev.global_version_id
|
625
|
+
WHERE ev.environment_id = ? AND ev.is_active = 1
|
626
|
+
AND gv.last_used_at >= datetime('now', '-7 days')""",
|
627
|
+
(environment_id,)
|
628
|
+
)
|
629
|
+
stats["recent_activity"] = {
|
630
|
+
"versions_used_last_7_days": (await cursor.fetchone())[0]
|
631
|
+
}
|
632
|
+
|
633
|
+
return stats
|
@@ -4,7 +4,7 @@ import hashlib
|
|
4
4
|
import json
|
5
5
|
from dataclasses import dataclass, field
|
6
6
|
from datetime import datetime, timezone
|
7
|
-
from enum import Enum
|
7
|
+
from enum import Enum, StrEnum
|
8
8
|
from pathlib import Path
|
9
9
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
10
10
|
|
@@ -14,7 +14,16 @@ if TYPE_CHECKING:
|
|
14
14
|
from typing import ForwardRef
|
15
15
|
|
16
16
|
|
17
|
-
|
17
|
+
def _ensure_str_for_json(field):
|
18
|
+
"""Ensure field is JSON-serializable as string.
|
19
|
+
|
20
|
+
StrEnum fields automatically serialize as strings, but this handles
|
21
|
+
the edge case where a field might be None or already a string.
|
22
|
+
"""
|
23
|
+
return field # StrEnum automatically converts to string, None stays None
|
24
|
+
|
25
|
+
|
26
|
+
class EntityCategory(StrEnum):
|
18
27
|
"""D365 F&O Entity Categories"""
|
19
28
|
|
20
29
|
MASTER = "Master"
|
@@ -25,7 +34,7 @@ class EntityCategory(Enum):
|
|
25
34
|
PARAMETERS = "Parameters"
|
26
35
|
|
27
36
|
|
28
|
-
class ODataXppType(
|
37
|
+
class ODataXppType(StrEnum):
|
29
38
|
"""D365 F&O OData XPP Types"""
|
30
39
|
|
31
40
|
CONTAINER = "Container"
|
@@ -42,7 +51,7 @@ class ODataXppType(Enum):
|
|
42
51
|
VOID = "Void"
|
43
52
|
|
44
53
|
|
45
|
-
class ODataBindingKind(
|
54
|
+
class ODataBindingKind(StrEnum):
|
46
55
|
"""D365 F&O Action Binding Types"""
|
47
56
|
|
48
57
|
BOUND_TO_ENTITY_INSTANCE = "BoundToEntityInstance"
|
@@ -50,7 +59,7 @@ class ODataBindingKind(Enum):
|
|
50
59
|
UNBOUND = "Unbound"
|
51
60
|
|
52
61
|
|
53
|
-
class SyncStrategy(
|
62
|
+
class SyncStrategy(StrEnum):
|
54
63
|
"""Metadata synchronization strategies"""
|
55
64
|
|
56
65
|
FULL = "full"
|
@@ -59,7 +68,7 @@ class SyncStrategy(Enum):
|
|
59
68
|
SHARING_MODE = "sharing_mode"
|
60
69
|
|
61
70
|
|
62
|
-
class Cardinality(
|
71
|
+
class Cardinality(StrEnum):
|
63
72
|
"""Navigation Property Cardinality"""
|
64
73
|
|
65
74
|
SINGLE = "Single"
|
@@ -191,7 +200,7 @@ class PublicEntityActionInfo:
|
|
191
200
|
def to_dict(self) -> Dict[str, Any]:
|
192
201
|
return {
|
193
202
|
"name": self.name,
|
194
|
-
"binding_kind": self.binding_kind
|
203
|
+
"binding_kind": self.binding_kind, # StrEnum automatically serializes as string
|
195
204
|
"parameters": [param.to_dict() for param in self.parameters],
|
196
205
|
"return_type": self.return_type.to_dict() if self.return_type else None,
|
197
206
|
"field_lookup": self.field_lookup,
|
@@ -221,7 +230,7 @@ class DataEntityInfo:
|
|
221
230
|
"label_text": self.label_text,
|
222
231
|
"data_service_enabled": self.data_service_enabled,
|
223
232
|
"data_management_enabled": self.data_management_enabled,
|
224
|
-
"entity_category": self.entity_category
|
233
|
+
"entity_category": self.entity_category, # StrEnum automatically serializes as string
|
225
234
|
"is_read_only": self.is_read_only,
|
226
235
|
}
|
227
236
|
|
@@ -438,7 +447,7 @@ class NavigationPropertyInfo:
|
|
438
447
|
"name": self.name,
|
439
448
|
"related_entity": self.related_entity,
|
440
449
|
"related_relation_name": self.related_relation_name,
|
441
|
-
"cardinality": self.cardinality
|
450
|
+
"cardinality": self.cardinality, # StrEnum automatically serializes as string
|
442
451
|
"constraints": [constraint.to_dict() for constraint in self.constraints],
|
443
452
|
}
|
444
453
|
|
@@ -503,7 +512,7 @@ class ActionInfo:
|
|
503
512
|
def to_dict(self) -> Dict[str, Any]:
|
504
513
|
return {
|
505
514
|
"name": self.name,
|
506
|
-
"binding_kind": self.binding_kind
|
515
|
+
"binding_kind": self.binding_kind, # StrEnum automatically serializes as string
|
507
516
|
"entity_name": self.entity_name,
|
508
517
|
"entity_set_name": self.entity_set_name,
|
509
518
|
"parameters": [param.to_dict() for param in self.parameters],
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/action_execution.py
RENAMED
File without changes
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/sequence_analysis.py
RENAMED
File without changes
|
File without changes
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/database_handler.py
RENAMED
File without changes
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/entity_handler.py
RENAMED
File without changes
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/environment_handler.py
RENAMED
File without changes
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/metadata_handler.py
RENAMED
File without changes
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/query_handler.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/search_engine_v2.py
RENAMED
File without changes
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/sync_manager_v2.py
RENAMED
File without changes
|
{d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/version_detector.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|